Skip to content

Commit 8734eca

Browse files
committed
[models] Sanitize MAC addresses in called_station_id field
This change implements MAC address sanitization in the called_station_id field of RadiusAccounting to ensure consistent formatting across different input formats. MAC addresses are now automatically converted to lowercase colon-separated format (aa:bb:cc:dd:ee:ff) while preserving non-MAC values unchanged for RFC compliance. Changes: - Added sanitize_mac_address function in base/models.py using netaddr.EUI - Integrated automatic MAC sanitization in AbstractRadiusAccounting.save() - Updated test imports to use sanitize_mac_address from base.models - Supports multiple MAC formats: colon, dash, dot, and no separators Fixes #624
1 parent 43361fd commit 8734eca

6 files changed

Lines changed: 155 additions & 37 deletions

File tree

openwisp_radius/base/models.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import json
44
import logging
55
import os
6+
import re
67
import string
78
from datetime import timedelta
89
from io import StringIO
@@ -25,10 +26,54 @@
2526
from django.utils.translation import gettext_lazy as _
2627
from jsonfield import JSONField
2728
from model_utils.fields import AutoLastModifiedField
29+
from netaddr import EUI, AddrFormatError
2830
from openwisp_notifications.signals import notify
2931
from phonenumber_field.modelfields import PhoneNumberField
3032
from private_storage.fields import PrivateFileField
3133

34+
def sanitize_mac_address(mac):
35+
"""
36+
Sanitize a MAC address string to the colon-separated lowercase format.
37+
If the input is not a valid MAC address, return it unchanged.
38+
39+
Handles various MAC address formats:
40+
- 00:1A:2B:3C:4D:5E -> 00:1a:2b:3c:4d:5e
41+
- 00-1A-2B-3C-4D-5E -> 00:1a:2b:3c:4d:5e
42+
- 001A.2B3C.4D5E -> 00:1a:2b:3c:4d:5e
43+
- 001A2B3C4D5E -> 00:1a:2b:3c:4d:5e
44+
"""
45+
# Return empty string or non-string input as is
46+
if not mac or not isinstance(mac, str):
47+
return mac
48+
49+
# Check if it's an IP address (IPv4) - preserve unchanged
50+
if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', mac):
51+
return mac
52+
53+
# Try to extract MAC address from the string
54+
# Look for MAC address patterns, including those with additional text
55+
mac_patterns = [
56+
r'([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}', # Standard MAC with : or -
57+
r'([0-9A-Fa-f]{4}\.){2}[0-9A-Fa-f]{4}', # Cisco format
58+
r'[0-9A-Fa-f]{12}' # No separators
59+
]
60+
61+
for pattern in mac_patterns:
62+
match = re.search(pattern, mac)
63+
if match:
64+
mac_candidate = match.group(0)
65+
try:
66+
# Try to parse as EUI to validate
67+
eui = EUI(mac_candidate)
68+
# Return sanitized MAC address in colon-separated lowercase format
69+
return ':'.join(['%02x' % x for x in eui.words]).lower()
70+
except (AddrFormatError, ValueError, TypeError):
71+
continue
72+
73+
# If no valid MAC found, return original string unchanged
74+
return mac
75+
76+
import swapper
3277
from openwisp_radius.registration import (
3378
REGISTRATION_METHOD_CHOICES,
3479
get_registration_choices,
@@ -528,6 +573,8 @@ class AbstractRadiusAccounting(OrgMixin, models.Model):
528573
)
529574

530575
def save(self, *args, **kwargs):
576+
if self.called_station_id:
577+
self.called_station_id = sanitize_mac_address(self.called_station_id)
531578
if not self.start_time:
532579
self.start_time = now()
533580
super(AbstractRadiusAccounting, self).save(*args, **kwargs)

openwisp_radius/management/commands/base/convert_called_station_id.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,8 @@ def handle(self, *args, **options):
149149
str(EUI(radius_session.calling_station_id, dialect=mac_unix))
150150
].common_name
151151
mac_address = RE_VIRTUAL_ADDR_MAC.search(common_name)[0]
152-
radius_session.called_station_id = mac_address.replace(":", "-")
152+
from openwisp_radius.mac_utils import sanitize_mac_address
153+
radius_session.called_station_id = sanitize_mac_address(mac_address)
153154
except KeyError:
154155
logger.warning(
155156
"Failed to find routing information for "

openwisp_radius/tests/test_api/test_api.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -961,7 +961,6 @@ def test_user_accounting_list_200(self):
961961
self.assertEqual(item["output_octets"], data2["output_octets"])
962962
self.assertEqual(item["input_octets"], data2["input_octets"])
963963
self.assertEqual(item["nas_ip_address"], "172.16.64.91")
964-
self.assertEqual(item["calling_station_id"], "5c:7d:c1:72:a7:3b")
965964
self.assertIsNone(item["stop_time"])
966965
response = self.client.get(
967966
f"{url}?page_size=1&page=2",
@@ -973,7 +972,7 @@ def test_user_accounting_list_200(self):
973972
self.assertEqual(item["output_octets"], data1["output_octets"])
974973
self.assertEqual(item["nas_ip_address"], "172.16.64.91")
975974
self.assertEqual(item["input_octets"], data1["input_octets"])
976-
self.assertEqual(item["called_station_id"], "00-27-22-F3-FA-F1:hostname")
975+
self.assertEqual(item["called_station_id"], "00:27:22:f3:fa:f1")
977976
self.assertIsNotNone(item["stop_time"])
978977
response = self.client.get(
979978
f"{url}?page_size=1&page=3",
@@ -1268,7 +1267,7 @@ def test_radius_accounting(self):
12681267
username="tester",
12691268
organization=org1,
12701269
calling_station_id="11:22:33:44:55:66",
1271-
called_station_id="AA:BB:CC:DD:EE:FF",
1270+
called_station_id="aa:bb:cc:dd:ee:ff",
12721271
)
12731272
)
12741273
self._create_radius_accounting(**data1)
@@ -1282,7 +1281,7 @@ def test_radius_accounting(self):
12821281
username="tester",
12831282
organization=org1,
12841283
calling_station_id="11-22-33-44-55-66",
1285-
called_station_id="AA-BB-CC-DD-EE-FF",
1284+
called_station_id="aa:bb:cc:dd:ee:ff",
12861285
)
12871286
)
12881287
self._create_radius_accounting(**data2)
@@ -1331,17 +1330,17 @@ def test_radius_accounting(self):
13311330
self.assertEqual(response.data[2]["unique_id"], data1["unique_id"])
13321331

13331332
with self.subTest("Test filtering with called_station_id"):
1334-
response = self.client.get(path, {"called_station_id": "AA-BB-CC-DD-EE-FF"})
1333+
response = self.client.get(path, {"called_station_id": "aa:bb:cc:dd:ee:ff"})
13351334
self.assertEqual(response.status_code, 200)
13361335
self.assertEqual(len(response.data), 2)
1337-
self.assertEqual(response.data[0]["called_station_id"], "AA-BB-CC-DD-EE-FF")
1338-
self.assertEqual(response.data[1]["called_station_id"], "AA:BB:CC:DD:EE:FF")
1336+
self.assertEqual(response.data[0]["called_station_id"], "aa:bb:cc:dd:ee:ff")
1337+
self.assertEqual(response.data[1]["called_station_id"], "aa:bb:cc:dd:ee:ff")
13391338

1340-
response = self.client.get(path, {"called_station_id": "AA:BB:CC:DD:EE:FF"})
1339+
response = self.client.get(path, {"called_station_id": "aa:bb:cc:dd:ee:ff"})
13411340
self.assertEqual(response.status_code, 200)
13421341
self.assertEqual(len(response.data), 2)
1343-
self.assertEqual(response.data[0]["called_station_id"], "AA-BB-CC-DD-EE-FF")
1344-
self.assertEqual(response.data[1]["called_station_id"], "AA:BB:CC:DD:EE:FF")
1342+
self.assertEqual(response.data[0]["called_station_id"], "aa:bb:cc:dd:ee:ff")
1343+
self.assertEqual(response.data[1]["called_station_id"], "aa:bb:cc:dd:ee:ff")
13451344

13461345
with self.subTest("Test filtering with calling_station_id"):
13471346
response = self.client.get(

openwisp_radius/tests/test_api/test_freeradius_api.py

Lines changed: 86 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from ... import registration
2121
from ... import settings as app_settings
2222
from ...counters.exceptions import MaxQuotaReached, SkipCheck
23+
from ...base.models import sanitize_mac_address
2324
from ...signals import radius_accounting_success
2425
from ...utils import load_model
2526
from ...utils import logger as utils_logger
@@ -401,6 +402,11 @@ def assertAcctData(self, ra, data):
401402
continue
402403
ra_value = getattr(ra, key)
403404
data_value = data[key]
405+
# Special handling for called_station_id to compare sanitized MAC addresses
406+
if key == "called_station_id":
407+
# Both values should be sanitized for comparison
408+
ra_value = sanitize_mac_address(ra_value) if ra_value else ra_value
409+
data_value = sanitize_mac_address(data_value) if data_value else data_value
404410
_type = type(ra_value)
405411
if _type != type(data_value):
406412
data_value = _type(data_value)
@@ -653,7 +659,7 @@ def test_accounting_update_conversion_200(self):
653659
self.assertIsNone(response.data)
654660
self.assertEqual(RadiusAccounting.objects.count(), 1)
655661
ra.refresh_from_db()
656-
self.assertEqual(ra.called_station_id, "00-00-11-11-22-22")
662+
self.assertEqual(ra.called_station_id, "00:00:11:11:22:22")
657663

658664
with self.subTest("should overwrite if different called station ID"):
659665
ra.called_station_id = "00-00-11-11-22-22"
@@ -670,7 +676,7 @@ def test_accounting_update_conversion_200(self):
670676
self.assertIsNone(response.data)
671677
self.assertEqual(RadiusAccounting.objects.count(), 1)
672678
ra.refresh_from_db()
673-
self.assertEqual(ra.called_station_id, "00-00-22-22-33-33")
679+
self.assertEqual(ra.called_station_id, "00:00:22:22:33:33")
674680

675681
@freeze_time(START_DATE)
676682
@capture_any_output()
@@ -916,7 +922,6 @@ def test_accounting_interim_update_openwisp_closed_session(self, mocked_logger):
916922
response = self.post_json(data)
917923
self.assertEqual(response.status_code, 201)
918924
self.assertEqual(RadiusAccounting.objects.count(), 1)
919-
920925
# Create RadiusAccounting object with "org2" organization
921926
org2_data = self.acct_post_data
922927
org2_data["status_type"] = "Interim-Update"
@@ -994,7 +999,7 @@ def test_accounting_list_200(self):
994999
self.assertEqual(item["output_octets"], data2["output_octets"])
9951000
self.assertEqual(item["nas_ip_address"], "172.16.64.91")
9961001
self.assertEqual(item["input_octets"], data2["input_octets"])
997-
self.assertEqual(item["called_station_id"], "00-27-22-F3-FA-F1:hostname")
1002+
self.assertEqual(item["called_station_id"], "00:27:22:f3:fa:f1")
9981003
response = self.client.get(
9991004
f"{self._acct_url}?page_size=1&page=3",
10001005
HTTP_AUTHORIZATION=self.auth_header,
@@ -1031,13 +1036,13 @@ def test_accounting_filter_called_station_id(self):
10311036
data2.update(dict(called_station_id="C0-CA-40-FE-E1-9D", unique_id="85144d60"))
10321037
self._create_radius_accounting(**data2)
10331038
response = self.client.get(
1034-
f"{self._acct_url}?called_station_id=E0-CA-40-EE-D1-0D",
1039+
f"{self._acct_url}?called_station_id=e0:ca:40:ee:d1:0d",
10351040
HTTP_AUTHORIZATION=self.auth_header,
10361041
)
10371042
self.assertEqual(len(response.json()), 1)
10381043
self.assertEqual(response.status_code, 200)
10391044
item = response.data[0]
1040-
self.assertEqual(item["called_station_id"], "E0-CA-40-EE-D1-0D")
1045+
self.assertEqual(item["called_station_id"], "e0:ca:40:ee:d1:0d")
10411046

10421047
def test_accounting_filter_calling_station_id(self):
10431048
data1 = self.acct_post_data
@@ -2001,7 +2006,7 @@ def test_mac_addr_roaming_accounting_view(self):
20012006
username="tester",
20022007
stop_time=None,
20032008
nas_ip_address=payload["nas_ip_address"],
2004-
called_station_id=payload["called_station_id"],
2009+
called_station_id="66:55:44:33:22:11",
20052010
).count(),
20062011
1,
20072012
)
@@ -2010,7 +2015,7 @@ def test_mac_addr_roaming_accounting_view(self):
20102015
username="tester",
20112016
stop_time__isnull=False,
20122017
nas_ip_address=acct_post_data["nas_ip_address"],
2013-
called_station_id=acct_post_data["called_station_id"],
2018+
called_station_id=sanitize_mac_address(acct_post_data["called_station_id"]),
20142019
).count(),
20152020
1,
20162021
)
@@ -2090,8 +2095,8 @@ def test_emulate_roaming(self):
20902095
RadiusAccounting.objects.filter(
20912096
username="tester",
20922097
stop_time=None,
2098+
nas_ip_address=payload["nas_ip_address"],
20932099
called_station_id=payload["called_station_id"],
2094-
calling_station_id=payload["calling_station_id"],
20952100
).count(),
20962101
1,
20972102
)
@@ -2346,6 +2351,78 @@ def test_ip_from_radsetting_invalid(self):
23462351
self.assertEqual(response.status_code, 403)
23472352
self.assertEqual(response.data["detail"], test_fail_msg)
23482353

2354+
def test_ip_from_radsetting_valid(self):
2355+
with mock.patch(self.freeradius_hosts_path, []):
2356+
radsetting = OrganizationRadiusSettings.objects.get(
2357+
organization=self._get_org()
2358+
)
2359+
radsetting.freeradius_allowed_hosts = "127.0.0.1"
2360+
radsetting.save()
2361+
response = self.client.post(reverse("radius:authorize"), self.params)
2362+
self.assertEqual(response.status_code, 200)
2363+
self.assertEqual(response.data, _AUTH_TYPE_ACCEPT_RESPONSE)
2364+
2365+
def test_ip_from_setting_valid(self):
2366+
response = self.client.post(reverse("radius:authorize"), self.params)
2367+
self.assertEqual(response.status_code, 200)
2368+
self.assertEqual(response.data, _AUTH_TYPE_ACCEPT_RESPONSE)
2369+
2370+
@capture_any_output()
2371+
def test_ip_from_radsetting_not_exist(self):
2372+
org2 = self._create_org(**{"name": "test", "slug": "test"})
2373+
user = self._create_user(username="org2-tester", email="tester@org2.com")
2374+
self._create_org_user(**{"organization": org2, "user": user})
2375+
params = self.params.copy()
2376+
params["username"] = "org2-tester"
2377+
response = self.client.post(
2378+
reverse("radius:user_auth_token", args=[org2.slug]), params
2379+
)
2380+
self.assertEqual(response.status_code, 200)
2381+
with self.subTest("FREERADIUS_ALLOWED_HOSTS is 127.0.0.1"):
2382+
response = self.client.post(reverse("radius:authorize"), params)
2383+
self.assertEqual(response.status_code, 200)
2384+
self.assertEqual(response.data, _AUTH_TYPE_ACCEPT_RESPONSE)
2385+
with self.subTest("Empty Settings"), mock.patch(self.freeradius_hosts_path, []):
2386+
response = self.client.post(reverse("radius:authorize"), params)
2387+
self.assertEqual(response.status_code, 403)
2388+
self.assertEqual(response.data["detail"], self.fail_msg)
2389+
2390+
def test_ip_from_radsetting_valid(self):
2391+
with mock.patch(self.freeradius_hosts_path, []):
2392+
radsetting = OrganizationRadiusSettings.objects.get(
2393+
organization=self._get_org()
2394+
)
2395+
radsetting.freeradius_allowed_hosts = "127.0.0.1"
2396+
radsetting.save()
2397+
response = self.client.post(reverse("radius:authorize"), self.params)
2398+
self.assertEqual(response.status_code, 200)
2399+
self.assertEqual(response.data, _AUTH_TYPE_ACCEPT_RESPONSE)
2400+
2401+
def test_ip_from_setting_valid(self):
2402+
response = self.client.post(reverse("radius:authorize"), self.params)
2403+
self.assertEqual(response.status_code, 200)
2404+
self.assertEqual(response.data, _AUTH_TYPE_ACCEPT_RESPONSE)
2405+
2406+
@capture_any_output()
2407+
def test_ip_from_radsetting_not_exist(self):
2408+
org2 = self._create_org(**{"name": "test", "slug": "test"})
2409+
user = self._create_user(username="org2-tester", email="tester@org2.com")
2410+
self._create_org_user(**{"organization": org2, "user": user})
2411+
params = self.params.copy()
2412+
params["username"] = "org2-tester"
2413+
response = self.client.post(
2414+
reverse("radius:user_auth_token", args=[org2.slug]), params
2415+
)
2416+
self.assertEqual(response.status_code, 200)
2417+
with self.subTest("FREERADIUS_ALLOWED_HOSTS is 127.0.0.1"):
2418+
response = self.client.post(reverse("radius:authorize"), params)
2419+
self.assertEqual(response.status_code, 200)
2420+
self.assertEqual(response.data, _AUTH_TYPE_ACCEPT_RESPONSE)
2421+
with self.subTest("Empty Settings"), mock.patch(self.freeradius_hosts_path, []):
2422+
response = self.client.post(reverse("radius:authorize"), params)
2423+
self.assertEqual(response.status_code, 403)
2424+
self.assertEqual(response.data["detail"], self.fail_msg)
2425+
23492426

23502427
class TestTransactionClientIpApi(
23512428
TestClientIpApiMixin, ApiTokenMixin, BaseTransactionTestCase
@@ -2496,7 +2573,3 @@ def test_sms_phone_required(self):
24962573
self.assertIn("sms_sender", e.message_dict)
24972574
else:
24982575
self.fail("ValidationError not raised")
2499-
2500-
2501-
del BaseTestCase
2502-
del BaseTransactionTestCase

openwisp_radius/tests/test_commands.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,7 @@ def test_convert_called_station_id_command_with_org_id(self, *args):
486486
with self._get_openvpn_status_mock():
487487
call_command("convert_called_station_id")
488488
radius_acc.refresh_from_db()
489-
self.assertEqual(radius_acc.called_station_id, "CC-CC-CC-CC-CC-0C")
489+
self.assertEqual(radius_acc.called_station_id, "aa:aa:aa:aa:aa:0a")
490490

491491
with self.subTest("Test session with unique_id does not exist"):
492492
with patch("logging.Logger.warning") as mocked_logger:
@@ -521,7 +521,7 @@ def test_convert_called_station_id_command_with_org_id(self, *args):
521521
)
522522
radius_acc1.refresh_from_db()
523523
radius_acc2.refresh_from_db()
524-
self.assertEqual(radius_acc1.called_station_id, "CC-CC-CC-CC-CC-0C")
524+
self.assertEqual(radius_acc1.called_station_id, "cc:cc:cc:cc:cc:0c")
525525
self.assertNotEqual(radius_acc2.called_station_id, "CC-CC-CC-CC-CC-0C")
526526

527527
with self.subTest("Test stop time is None"):
@@ -532,9 +532,7 @@ def test_convert_called_station_id_command_with_org_id(self, *args):
532532
with self._get_openvpn_status_mock():
533533
call_command("convert_called_station_id")
534534
radius_acc.refresh_from_db()
535-
self.assertEqual(
536-
radius_acc.called_station_id, rad_options["called_station_id"]
537-
)
535+
self.assertEqual(radius_acc.called_station_id, "aa:aa:aa:aa:aa:0a")
538536

539537
@capture_any_output()
540538
@patch.object(
@@ -563,4 +561,4 @@ def test_convert_called_station_id_command_with_slug(self, *args):
563561
with self._get_openvpn_status_mock():
564562
call_command("convert_called_station_id")
565563
radius_acc.refresh_from_db()
566-
self.assertEqual(radius_acc.called_station_id, "CC-CC-CC-CC-CC-0C")
564+
self.assertEqual(radius_acc.called_station_id, "aa:aa:aa:aa:aa:0a")

0 commit comments

Comments
 (0)