Skip to content

Commit be3f41a

Browse files
committed
[fix] Avoid mutating device name in-place inside _get_common_name()
1 parent 8cf6733 commit be3f41a

File tree

3 files changed

+106
-6
lines changed

3 files changed

+106
-6
lines changed

openwisp_controller/config/base/vpn.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -892,12 +892,20 @@ def _get_common_name(self):
892892
"""
893893
d = self.config.device
894894
end = 63 - len(d.mac_address)
895-
d.name = d.name[:end]
895+
# Use a local variable to avoid mutating the device instance in-place.
896+
# Mutating d.name would corrupt the device object shared with the caller
897+
# (Django caches FK targets), causing the registration response hostname
898+
# and any subsequent device.save() to silently persist the truncated name.
899+
name = d.name[:end]
896900
unique_slug = shortuuid.ShortUUID().random(length=8)
897901
cn_format = app_settings.COMMON_NAME_FORMAT
898-
if cn_format == "{mac_address}-{name}" and d.name == d.mac_address:
902+
if cn_format == "{mac_address}-{name}" and name == d.mac_address:
899903
cn_format = "{mac_address}"
900-
common_name = cn_format.format(**d.__dict__)[:55]
904+
# Build the format context explicitly so custom COMMON_NAME_FORMAT strings
905+
# that reference other device attributes (e.g. {hardware_id}) still work,
906+
# while the truncated name is used without touching the device object.
907+
context = {**d.__dict__, "name": name}
908+
common_name = cn_format.format(**context)[:55]
901909
common_name = f"{common_name}-{unique_slug}"
902910
return common_name
903911

openwisp_controller/config/tests/test_controller.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1289,6 +1289,51 @@ def test_register_template_tags(self):
12891289
self.assertEqual(d.config.templates.filter(pk=t_shared.pk).count(), 1)
12901290
self.assertEqual(d.config.templates.filter(pk=t2.pk).count(), 0)
12911291

1292+
def test_register_hostname_not_truncated_by_vpn_cert_provisioning(self):
1293+
"""
1294+
When a new device with a long name registers and a VPN template with
1295+
auto_cert=True is applied via tags, _get_common_name() must not mutate
1296+
the in-memory device object. The `hostname` field in the registration
1297+
response and the name stored in the database must both equal the full
1298+
original name, not a cert-length-truncated version.
1299+
"""
1300+
# Name longer than the truncation threshold used in _get_common_name()
1301+
# (63 - len("00:11:22:33:44:55") = 46 chars).
1302+
long_name = "a-very-long-device-hostname-that-exceeds-46-chars"
1303+
self.assertGreater(len(long_name), 46)
1304+
1305+
org = self._create_org(name="org1")
1306+
vpn = self._create_vpn(organization=org)
1307+
# VPN template tagged "openvpn" with auto_cert so _get_common_name() runs.
1308+
t = self._create_template(
1309+
name="vpn-tpl",
1310+
organization=org,
1311+
type="vpn",
1312+
auto_cert=True,
1313+
vpn=vpn,
1314+
)
1315+
t.tags.add("openvpn")
1316+
1317+
response = self.client.post(
1318+
self.register_url,
1319+
{
1320+
"secret": TEST_ORG_SHARED_SECRET,
1321+
"name": long_name,
1322+
"mac_address": TEST_MACADDR,
1323+
"backend": "netjsonconfig.OpenWrt",
1324+
"tags": "openvpn",
1325+
},
1326+
)
1327+
self.assertEqual(response.status_code, 201)
1328+
1329+
# The `hostname` line in the response must be the full original name.
1330+
response_text = response.content.decode()
1331+
self.assertIn(f"hostname: {long_name}", response_text)
1332+
1333+
# The database record must also store the full original name.
1334+
device = Device.objects.get(mac_address=TEST_MACADDR)
1335+
self.assertEqual(device.name, long_name)
1336+
12921337
@capture_any_output()
12931338
def test_register_400(self):
12941339
self._get_org()

openwisp_controller/config/tests/test_vpn.py

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -414,13 +414,60 @@ def test_auto_create_cert_with_long_device_name(self):
414414
client.full_clean()
415415
client.save()
416416
# The last 9 characters gets truncated and replaced with unique id
417-
self.assertIn(
418-
"{mac_address}-{name}".format(**d.__dict__)[:-9], client._get_common_name()
419-
)
417+
mac = d.mac_address
418+
self.assertIn(f"{mac}-{device_name}"[:-9], client._get_common_name())
420419
self.assertEqual(len(client._get_common_name()), 64)
421420
cert = Cert.objects.filter(organization=org, name=device_name)
422421
self.assertEqual(cert.count(), 1)
423422
self.assertEqual(cert.first().common_name[:-9], client._get_common_name()[:-9])
423+
# The device name must NOT be mutated by _get_common_name()
424+
self.assertEqual(d.name, device_name)
425+
426+
def test_get_common_name_does_not_mutate_device_name(self):
427+
"""
428+
_get_common_name() must not modify the device.name attribute.
429+
Django caches FK targets, so mutating the device inside _get_common_name()
430+
would corrupt the caller's reference to the same object and could cause
431+
a truncated name to be saved to the database on a subsequent device.save().
432+
"""
433+
# Use a name longer than the truncation threshold (63 - len(mac) = 46 chars
434+
# for a standard 17-char MAC address).
435+
long_name = "a" * 50
436+
org = self._create_org(name="org1")
437+
vpn = self._create_vpn(organization=org)
438+
d = self._create_device(organization=org, name=long_name)
439+
c = self._create_config(device=d)
440+
client = VpnClient(vpn=vpn, config=c, auto_cert=True)
441+
442+
client._get_common_name()
443+
444+
# The in-memory device name must remain unchanged after _get_common_name().
445+
self.assertEqual(d.name, long_name)
446+
# The database record must also be unaffected.
447+
self.assertEqual(
448+
d.__class__.objects.values_list("name", flat=True).get(pk=d.pk),
449+
long_name,
450+
)
451+
452+
def test_get_common_name_does_not_mutate_device_name_short_name(self):
453+
"""
454+
For device names below the truncation threshold _get_common_name()
455+
must still not touch the device object even though the slice is a no-op.
456+
"""
457+
short_name = "router-01"
458+
org = self._create_org(name="org1")
459+
vpn = self._create_vpn(organization=org)
460+
d = self._create_device(organization=org, name=short_name)
461+
c = self._create_config(device=d)
462+
client = VpnClient(vpn=vpn, config=c, auto_cert=True)
463+
464+
# Capture the actual string object stored on the device before the call.
465+
name_before = d.__dict__["name"]
466+
client._get_common_name()
467+
468+
self.assertEqual(d.name, short_name)
469+
# The same string object must still be on the device — not a fresh slice.
470+
self.assertIs(d.__dict__["name"], name_before)
424471

425472
@mock.patch.object(Vpn, "dhparam", side_effect=SoftTimeLimitExceeded)
426473
def test_update_vpn_dh_timeout(self, dhparam):

0 commit comments

Comments
 (0)