Skip to content

Commit d971ef7

Browse files
committed
[feature] Sanitize MAC addresses in called_station_id field
1 parent eddc4c6 commit d971ef7

14 files changed

Lines changed: 549 additions & 236 deletions

openwisp_radius/api/freeradius_views.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
from .. import registration
3030
from .. import settings as app_settings
31+
from ..base.models import sanitize_mac_address
3132
from ..counters.base import BaseCounter
3233
from ..counters.exceptions import MaxQuotaReached
3334
from ..signals import radius_accounting_success
@@ -372,8 +373,8 @@ def _check_simultaneous_use(
372373
# of the same client on the same NAS.
373374
if called_station_id and calling_station_id:
374375
open_sessions = open_sessions.exclude(
375-
called_station_id=called_station_id,
376-
calling_station_id=calling_station_id,
376+
called_station_id=sanitize_mac_address(called_station_id),
377+
calling_station_id=sanitize_mac_address(calling_station_id),
377378
)
378379
open_sessions = open_sessions.count()
379380
if open_sessions >= max_simultaneous:

openwisp_radius/base/models.py

Lines changed: 53 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -31,49 +31,6 @@
3131
from phonenumber_field.modelfields import PhoneNumberField
3232
from private_storage.fields import PrivateFileField
3333

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
7734
from openwisp_radius.registration import (
7835
REGISTRATION_METHOD_CHOICES,
7936
get_registration_choices,
@@ -223,6 +180,53 @@ def sanitize_mac_address(mac):
223180
OPTIONAL_SETTINGS = app_settings.OPTIONAL_REGISTRATION_FIELDS
224181

225182

183+
def sanitize_mac_address(mac):
184+
"""
185+
Sanitize a MAC address string to the colon-separated lowercase format.
186+
If the input is not a valid MAC address, return it unchanged.
187+
188+
Handles various MAC address formats:
189+
- 00:1A:2B:3C:4D:5E -> 00:1a:2b:3c:4d:5e
190+
- 00-1A-2B-3C-4D-5E -> 00:1a:2b:3c:4d:5e
191+
- 001A.2B3C.4D5E -> 00:1a:2b:3c:4d:5e
192+
- 001A2B3C4D5E -> 00:1a:2b:3c:4d:5e
193+
"""
194+
if not mac or not isinstance(mac, str):
195+
return mac
196+
try:
197+
ipaddress.ip_address(mac)
198+
return mac
199+
except ValueError:
200+
pass
201+
mac_patterns = [
202+
r"([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}",
203+
r"([0-9A-Fa-f]{4}\.){2}[0-9A-Fa-f]{4}",
204+
r"[0-9A-Fa-f]{12}",
205+
]
206+
for pattern in mac_patterns:
207+
if re.fullmatch(pattern, mac):
208+
try:
209+
eui = EUI(mac)
210+
return ":".join([f"{x:02x}" for x in eui.words]).lower()
211+
except (AddrFormatError, ValueError, TypeError):
212+
continue
213+
return mac
214+
215+
216+
class SanitizedMacCharField(models.CharField):
217+
"""
218+
A CharField that automatically sanitizes MAC addresses
219+
to the canonical lowercase colon-separated format on save.
220+
"""
221+
222+
def pre_save(self, model_instance, add):
223+
value = getattr(model_instance, self.attname)
224+
if value:
225+
value = sanitize_mac_address(value)
226+
setattr(model_instance, self.attname, value)
227+
return value
228+
229+
226230
class AutoUsernameMixin(object):
227231
def clean(self):
228232
"""
@@ -495,15 +499,15 @@ class AbstractRadiusAccounting(OrgMixin, models.Model):
495499
null=True,
496500
blank=True,
497501
)
498-
called_station_id = models.CharField(
502+
called_station_id = SanitizedMacCharField(
499503
verbose_name=_("called station ID"),
500504
max_length=253,
501505
db_column="calledstationid",
502506
db_index=True,
503507
blank=True,
504508
null=True,
505509
)
506-
calling_station_id = models.CharField(
510+
calling_station_id = SanitizedMacCharField(
507511
verbose_name=_("calling station ID"),
508512
max_length=253,
509513
db_column="callingstationid",
@@ -573,8 +577,6 @@ class AbstractRadiusAccounting(OrgMixin, models.Model):
573577
)
574578

575579
def save(self, *args, **kwargs):
576-
if self.called_station_id:
577-
self.called_station_id = sanitize_mac_address(self.called_station_id)
578580
if not self.start_time:
579581
self.start_time = now()
580582
super(AbstractRadiusAccounting, self).save(*args, **kwargs)
@@ -624,8 +626,9 @@ def _close_stale_sessions_on_nas_boot(cls, called_station_id):
624626
"""
625627
if not called_station_id:
626628
return 0
629+
sanitized_called_station_id = sanitize_mac_address(called_station_id)
627630
stale_sessions = cls.objects.filter(
628-
called_station_id=called_station_id,
631+
called_station_id=sanitized_called_station_id,
629632
stop_time__isnull=True,
630633
)
631634
closed_count = stale_sessions.update(
@@ -877,14 +880,14 @@ class AbstractRadiusPostAuth(OrgMixin, UUIDModel):
877880
verbose_name=_("password"), max_length=64, db_column="pass", blank=True
878881
)
879882
reply = models.CharField(verbose_name=_("reply"), max_length=32)
880-
called_station_id = models.CharField(
883+
called_station_id = SanitizedMacCharField(
881884
verbose_name=_("called station ID"),
882885
max_length=253,
883886
db_column="calledstationid",
884887
blank=True,
885888
null=True,
886889
)
887-
calling_station_id = models.CharField(
890+
calling_station_id = SanitizedMacCharField(
888891
verbose_name=_("calling station ID"),
889892
max_length=253,
890893
db_column="callingstationid",

0 commit comments

Comments
 (0)