Skip to content

Commit b678253

Browse files
committed
[fix] Address extension validation follow-ups
Related to #98
1 parent ddb5f15 commit b678253

4 files changed

Lines changed: 134 additions & 9 deletions

File tree

django_x509/base/models.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,11 @@ def _get_extension_values(self, value):
601601
return value.split(",")
602602
return []
603603

604+
def _get_der_length_bytes(self, length):
605+
if length < 128:
606+
return bytes([length])
607+
return b"\x81" + bytes([length])
608+
604609
def _validate_supported_extensions(self):
605610
for index, ext in enumerate(self.extensions or []):
606611
if not isinstance(ext, dict):
@@ -623,7 +628,8 @@ def _validate_supported_extensions(self):
623628
self._raise_extensions_validation_error(
624629
f"{path}.value", _("nsComment extension requires a value.")
625630
)
626-
if len(value) > 255:
631+
value_bytes = value.encode("utf-8")
632+
if len(value_bytes) > 255:
627633
self._raise_extensions_validation_error(
628634
f"{path}.value",
629635
_("nsComment value exceeds maximum length of 255 bytes"),
@@ -760,9 +766,9 @@ def _add_extensions(self, builder, public_key):
760766
"emailprotection": ExtendedKeyUsageOID.EMAIL_PROTECTION,
761767
}
762768
oids = []
763-
values = val if isinstance(val, list) else val.split(",")
769+
values = self._get_extension_values(val)
764770
for v in values:
765-
v_clean = v.strip().lower()
771+
v_clean = str(v).strip().lower()
766772
if v_clean not in eku_map:
767773
raise ValidationError(
768774
_("Unsupported extendedKeyUsage value: %s") % v_clean
@@ -778,11 +784,14 @@ def _add_extensions(self, builder, public_key):
778784
raise ValidationError(
779785
_("nsComment extension requires a value.")
780786
)
781-
if len(val) > 255:
787+
val_bytes = val.encode("utf-8")
788+
if len(val_bytes) > 255:
782789
raise ValidationError(
783790
_("nsComment value exceeds maximum length of 255 bytes")
784791
)
785-
raw_val = b"\x16" + bytes([len(val)]) + val.encode("utf-8")
792+
raw_val = (
793+
b"\x16" + self._get_der_length_bytes(len(val_bytes)) + val_bytes
794+
)
786795
builder = builder.add_extension(
787796
x509.UnrecognizedExtension(
788797
x509.ObjectIdentifier("2.16.840.1.113730.1.13"), raw_val
@@ -801,9 +810,9 @@ def _add_extensions(self, builder, public_key):
801810
"objca": 0x01,
802811
}
803812
bits = 0
804-
values = val if isinstance(val, list) else val.split(",")
813+
values = self._get_extension_values(val)
805814
for v in values:
806-
v_clean = v.strip().lower()
815+
v_clean = str(v).strip().lower()
807816
if v_clean not in ns_cert_type_map:
808817
raise ValidationError(
809818
_("Unsupported nsCertType value: %s") % v_clean

django_x509/schemas.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def _build_extension_schema(name, title, description, value_schema):
1616
"critical": {"type": "boolean", "default": False},
1717
"value": value_schema,
1818
},
19-
"required": ["name", "critical", "value"],
19+
"required": ["name", "value"],
2020
}
2121

2222

django_x509/tests/test_admin.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from openwisp_utils.tests import AdminActionPermTestMixin
1212
from swapper import load_model
1313

14+
from django_x509.base.admin import BaseAdmin
15+
1416
from . import MessagingRequest
1517

1618
User = get_user_model()
@@ -252,6 +254,19 @@ def test_revoke_action(self):
252254
self.assertEqual(len(m), 1)
253255
self.assertEqual(str(m[0]), "1 certificate was revoked.")
254256

257+
def test_revoke_action_multiple(self):
258+
req = MessagingRequest()
259+
req.user = MockSuperUser()
260+
cert = Cert.objects.create(name="test_cert_2", ca=self.ca)
261+
ma = self.cert_admin(Cert, self.site)
262+
ma.revoke_action(req, [self.cert, cert])
263+
message = req.get_message_strings()
264+
self.assertEqual(message, ["2 certificates were revoked."])
265+
266+
def test_base_admin_default_extensions_schema(self):
267+
ma = BaseAdmin(Ca, self.site)
268+
self.assertEqual(ma.get_extensions_schema(), [])
269+
255270
def test_revoke_action_perms(self):
256271
user = User.objects.create(
257272
username="tester", email="tester@openwisp.org", is_staff=True

django_x509/tests/test_extensions.py

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
11
from copy import deepcopy
22

33
from cryptography import x509
4-
from django.core.exceptions import ValidationError
4+
from django.core.exceptions import ImproperlyConfigured, ValidationError
55
from django.test import TestCase
6+
from swapper import load_model
67

8+
from django_x509 import settings as app_settings
79
from django_x509.schemas import (
810
DEFAULT_CA_EXTENSIONS_SCHEMA,
911
DEFAULT_CERT_EXTENSIONS_SCHEMA,
1012
)
1113

1214
from . import TestX509Mixin
1315

16+
Cert = load_model("django_x509", "Cert")
17+
1418

1519
class ExtensionsSchemaTests(TestX509Mixin, TestCase):
20+
ns_comment_oid = x509.ObjectIdentifier("2.16.840.1.113730.1.13")
21+
1622
def test_default_ca_schema_rejects_cert_only_extension(self):
1723
with self.assertRaises(ValidationError) as context:
1824
self._create_ca(
@@ -73,6 +79,29 @@ def test_legacy_string_values_are_normalized(self):
7379
self.assertEqual(cert.extensions[0]["value"], ["client", "server"])
7480
self.assertEqual(cert.extensions[1]["value"], ["clientAuth", "serverAuth"])
7581

82+
def test_missing_critical_defaults_to_false(self):
83+
cert = self._create_cert(
84+
extensions=[{"name": "nsComment", "value": "comment without critical"}]
85+
)
86+
ns_comment = cert.x509.extensions.get_extension_for_oid(self.ns_comment_oid)
87+
self.assertFalse(cert.extensions[0].get("critical", False))
88+
self.assertFalse(ns_comment.critical)
89+
90+
def test_nscomment_rejects_values_over_255_utf8_bytes(self):
91+
with self.assertRaises(ValidationError) as context:
92+
self._create_cert(extensions=[{"name": "nsComment", "value": "é" * 128}])
93+
self.assertIn("extensions", context.exception.message_dict)
94+
self.assertIn(
95+
"nsComment value exceeds maximum length of 255 bytes",
96+
str(context.exception.message_dict["extensions"][0]),
97+
)
98+
99+
def test_nscomment_uses_long_form_der_length_for_128_bytes(self):
100+
cert = self._create_cert(extensions=[{"name": "nsComment", "value": "a" * 128}])
101+
ns_comment = cert.x509.extensions.get_extension_for_oid(self.ns_comment_oid)
102+
self.assertEqual(ns_comment.value.value[:3], b"\x16\x81\x80")
103+
self.assertEqual(ns_comment.value.value[3:], b"a" * 128)
104+
76105
def test_ca_schema_setting_override(self):
77106
schema = deepcopy(DEFAULT_CA_EXTENSIONS_SCHEMA)
78107
schema["items"]["oneOf"][0]["properties"]["value"]["minLength"] = 20
@@ -140,3 +169,75 @@ def test_schema_override_still_rejects_incompatible_nscomment_shape(self):
140169
"nsComment extension requires a string value.",
141170
str(context.exception.message_dict["extensions"][0]),
142171
)
172+
173+
def test_invalid_schema_override_raises_improperly_configured(self):
174+
with self.settings(DJANGO_X509_CA_EXTENSIONS_SCHEMA={"type": "madeup"}):
175+
with self.assertRaises(ImproperlyConfigured):
176+
app_settings.get_ca_extensions_schema()
177+
178+
def test_normalize_extensions_none_becomes_empty_list(self):
179+
cert = Cert()
180+
cert.extensions = None
181+
cert._normalize_extensions(DEFAULT_CERT_EXTENSIONS_SCHEMA)
182+
self.assertEqual(cert.extensions, [])
183+
184+
def test_normalize_extensions_keeps_non_dict_entries(self):
185+
cert = Cert()
186+
cert.extensions = ["invalid-entry"]
187+
cert._normalize_extensions(DEFAULT_CERT_EXTENSIONS_SCHEMA)
188+
self.assertEqual(cert.extensions, ["invalid-entry"])
189+
190+
def test_get_extension_values_returns_empty_list_for_unexpected_types(self):
191+
self.assertEqual(Cert()._get_extension_values({"unexpected": "mapping"}), [])
192+
193+
def test_raise_extensions_validation_error_without_path(self):
194+
with self.assertRaises(ValidationError) as context:
195+
Cert()._raise_extensions_validation_error("", "bad data")
196+
self.assertEqual(
197+
str(context.exception.message_dict["extensions"][0]),
198+
"Extensions data: bad data",
199+
)
200+
201+
def test_validate_supported_extensions_rejects_non_boolean_critical(self):
202+
cert = Cert()
203+
cert.extensions = [{"name": "nsComment", "critical": "yes", "value": "ok"}]
204+
with self.assertRaises(ValidationError) as context:
205+
cert._validate_supported_extensions()
206+
self.assertIn("extensions", context.exception.message_dict)
207+
self.assertIn(
208+
"Critical flag must be a boolean value.",
209+
str(context.exception.message_dict["extensions"][0]),
210+
)
211+
212+
def test_validate_supported_extensions_rejects_empty_nscomment_value(self):
213+
cert = Cert()
214+
cert.extensions = [{"name": "nsComment", "critical": False, "value": ""}]
215+
with self.assertRaises(ValidationError) as context:
216+
cert._validate_supported_extensions()
217+
self.assertIn("extensions", context.exception.message_dict)
218+
self.assertIn(
219+
"nsComment extension requires a value.",
220+
str(context.exception.message_dict["extensions"][0]),
221+
)
222+
223+
def test_validate_supported_extensions_rejects_empty_extended_key_usage(self):
224+
cert = Cert()
225+
cert.extensions = [{"name": "extendedKeyUsage", "critical": False, "value": []}]
226+
with self.assertRaises(ValidationError) as context:
227+
cert._validate_supported_extensions()
228+
self.assertIn("extensions", context.exception.message_dict)
229+
self.assertIn(
230+
"extendedKeyUsage extension requires at least one valid value.",
231+
str(context.exception.message_dict["extensions"][0]),
232+
)
233+
234+
def test_validate_supported_extensions_rejects_empty_ns_cert_type(self):
235+
cert = Cert()
236+
cert.extensions = [{"name": "nsCertType", "critical": False, "value": []}]
237+
with self.assertRaises(ValidationError) as context:
238+
cert._validate_supported_extensions()
239+
self.assertIn("extensions", context.exception.message_dict)
240+
self.assertIn(
241+
"nsCertType extension requires at least one valid type.",
242+
str(context.exception.message_dict["extensions"][0]),
243+
)

0 commit comments

Comments
 (0)