Skip to content

Commit 9986fe9

Browse files
committed
[models] Sanitize MAC addresses in called_station_id field #624
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 eddc4c6 commit 9986fe9

14 files changed

Lines changed: 538 additions & 230 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: 37 additions & 44 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,41 @@ 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+
match = re.search(pattern, mac)
208+
if match:
209+
mac_candidate = match.group(0)
210+
try:
211+
eui = EUI(mac_candidate)
212+
return ":".join(["%02x" % x for x in eui.words]).lower()
213+
except (AddrFormatError, ValueError, TypeError):
214+
continue
215+
return mac
216+
217+
226218
class AutoUsernameMixin(object):
227219
def clean(self):
228220
"""
@@ -624,8 +616,9 @@ def _close_stale_sessions_on_nas_boot(cls, called_station_id):
624616
"""
625617
if not called_station_id:
626618
return 0
619+
sanitized_called_station_id = sanitize_mac_address(called_station_id)
627620
stale_sessions = cls.objects.filter(
628-
called_station_id=called_station_id,
621+
called_station_id=sanitized_called_station_id,
629622
stop_time__isnull=True,
630623
)
631624
closed_count = stale_sessions.update(

openwisp_radius/management/commands/base/convert_called_station_id.py

Lines changed: 92 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,32 @@
33
from uuid import UUID
44

55
import openvpn_status
6+
import swapper
67
from django.core.management import BaseCommand
78
from Exscript.protocols import telnetlib
8-
from netaddr import EUI, mac_unix
9+
from netaddr import EUI, AddrFormatError, mac_unix
910

1011
from .... import settings as app_settings
12+
from ....base.models import sanitize_mac_address
1113
from ....utils import load_model
1214

13-
logger = logging.getLogger(__name__)
14-
1515
RE_VIRTUAL_ADDR_MAC = re.compile("^{0}:{0}:{0}:{0}:{0}:{0}".format("[a-f0-9]{2}"), re.I)
1616
TELNET_CONNECTION_TIMEOUT = 30 # In seconds
1717

18-
1918
RadiusAccounting = load_model("RadiusAccounting")
2019

20+
logger = logging.getLogger(__name__)
21+
2122

2223
class BaseConvertCalledStationIdCommand(BaseCommand):
24+
logger = logger
25+
26+
def _search_mac_address(self, common_name):
27+
match = RE_VIRTUAL_ADDR_MAC.search(common_name)
28+
if not match:
29+
raise IndexError(f"No MAC address found in '{common_name}'")
30+
return match[0]
31+
2332
help = "Correct Called Station IDs of Radius Sessions"
2433

2534
def _get_raw_management_info(self, host, port, password):
@@ -42,29 +51,29 @@ def _get_openvpn_routing_info(self, host, port=7505, password=None):
4251
try:
4352
raw_info = self._get_raw_management_info(host, port, password)
4453
except ConnectionRefusedError:
45-
logger.warning(
54+
BaseConvertCalledStationIdCommand.logger.warning(
4655
"Unable to establish telnet connection to "
4756
f"{host} on {port}. Skipping!"
4857
)
4958
return {}
5059
except (OSError, TimeoutError, EOFError) as error:
51-
logger.warning(
60+
BaseConvertCalledStationIdCommand.logger.warning(
5261
f"Error encountered while connecting to {host}:{port}: {error}. "
5362
"Skipping!"
5463
)
5564
return {}
5665
except Exception:
57-
logger.warning(
66+
BaseConvertCalledStationIdCommand.logger.warning(
5867
f"Error encountered while connecting to {host}:{port}. Skipping!"
5968
)
6069
return {}
6170
try:
6271
parsed_info = openvpn_status.parse_status(raw_info)
6372
return parsed_info.routing_table
6473
except openvpn_status.ParsingError as error:
65-
logger.warning(
74+
BaseConvertCalledStationIdCommand.logger.warning(
6675
"Unable to parse information received from "
67-
f"{host}:{port}. ParsingError: {error}. Skipping!",
76+
f"{host}:{port}. ParsingError: {error}. Skipping!"
6877
)
6978
return {}
7079

@@ -74,7 +83,7 @@ def _get_radius_session(self, unique_id):
7483
unique_id=unique_id
7584
)
7685
except RadiusAccounting.DoesNotExist:
77-
logger.warning(
86+
BaseConvertCalledStationIdCommand.logger.warning(
7887
f'RadiusAccount object with unique_id "{unique_id}" does not exist.'
7988
)
8089

@@ -88,24 +97,11 @@ def _get_called_station_setting(self, radius_session):
8897
# but will removed in future versions
8998
return {org_id: app_settings.CALLED_STATION_IDS[organization.slug]}
9099
except KeyError:
91-
logger.error(
100+
BaseConvertCalledStationIdCommand.logger.error(
92101
"OPENWISP_RADIUS_CALLED_STATION_IDS does not contain setting "
93102
f'for "{radius_session.organization.name}" organization'
94103
)
95104

96-
def _get_unconverted_sessions(self, org, unconverted_ids):
97-
lookup = dict(
98-
called_station_id__in=unconverted_ids,
99-
stop_time__isnull=True,
100-
)
101-
try:
102-
UUID(org)
103-
except ValueError:
104-
lookup["organization__slug"] = org
105-
else:
106-
lookup["organization__id"] = org
107-
return RadiusAccounting.objects.filter(**lookup).iterator()
108-
109105
def add_arguments(self, parser):
110106
parser.add_argument("--unique_id", action="store", type=str, default="")
111107

@@ -128,15 +124,24 @@ def handle(self, *args, **options):
128124
for org, config in called_station_id_setting.items():
129125
routing_dict = {}
130126
for openvpn_config in config["openvpn_config"]:
131-
routing_dict.update(
132-
self._get_openvpn_routing_info(
133-
openvpn_config["host"],
134-
openvpn_config.get("port", 7505),
135-
openvpn_config.get("password", None),
136-
)
127+
raw_routing = self.__class__._get_openvpn_routing_info(
128+
self,
129+
openvpn_config["host"],
130+
openvpn_config.get("port", 7505),
131+
openvpn_config.get("password", None),
137132
)
133+
normalized_routing = {}
134+
for k, v in raw_routing.items():
135+
try:
136+
norm_key = str(EUI(k, dialect=mac_unix)).lower()
137+
except Exception:
138+
norm_key = k.lower()
139+
normalized_routing[norm_key] = v
140+
routing_dict.update(normalized_routing)
138141
if not routing_dict:
139-
logger.info(f'No routing information found for "{org}" organization')
142+
BaseConvertCalledStationIdCommand.logger.info(
143+
f'No routing information found for "{org}" organization'
144+
)
140145
continue
141146

142147
if unique_id:
@@ -145,24 +150,68 @@ def handle(self, *args, **options):
145150
qs = self._get_unconverted_sessions(org, config["unconverted_ids"])
146151
for radius_session in qs:
147152
try:
148-
common_name = routing_dict[
149-
str(EUI(radius_session.calling_station_id, dialect=mac_unix))
150-
].common_name
151-
mac_address = RE_VIRTUAL_ADDR_MAC.search(common_name)[0]
152-
from openwisp_radius.mac_utils import sanitize_mac_address
153-
radius_session.called_station_id = sanitize_mac_address(mac_address)
154-
except KeyError:
155-
logger.warning(
156-
"Failed to find routing information for "
153+
lookup_key = str(
154+
EUI(radius_session.calling_station_id, dialect=mac_unix)
155+
).lower()
156+
except (AddrFormatError, ValueError, TypeError):
157+
BaseConvertCalledStationIdCommand.logger.warning(
158+
f"Invalid calling_station_id for session "
157159
f"{radius_session.session_id}. Skipping!"
158160
)
161+
continue
162+
if lookup_key not in routing_dict:
163+
164+
def _strip_leading_zeros(k):
165+
parts = k.split(":")
166+
return ":".join([p.lstrip("0") or "0" for p in parts])
167+
168+
alt_key = _strip_leading_zeros(lookup_key)
169+
if alt_key in routing_dict:
170+
routing_dict[lookup_key] = routing_dict[alt_key]
171+
else:
172+
BaseConvertCalledStationIdCommand.logger.warning(
173+
"Failed to find routing information for "
174+
f"{radius_session.session_id}. Skipping!"
175+
)
176+
continue
177+
178+
common_name = routing_dict[lookup_key].common_name
179+
180+
try:
181+
mac_address = self._search_mac_address(common_name)
159182
except (TypeError, IndexError):
160-
logger.warning(
183+
BaseConvertCalledStationIdCommand.logger.warning(
161184
f'Failed to find a MAC address in "{common_name}". '
162185
f"Skipping {radius_session.session_id}!"
163186
)
164-
else:
165-
radius_session.save()
187+
continue
188+
radius_session.called_station_id = sanitize_mac_address(mac_address)
189+
radius_session.save()
190+
191+
def _get_unconverted_sessions(self, org, unconverted_ids):
192+
"""
193+
Get unconverted sessions for the given organization and unconverted IDs.
194+
"""
195+
196+
if isinstance(org, str):
197+
try:
198+
org_uuid = UUID(org)
199+
except ValueError:
200+
Organization = swapper.load_model("openwisp_users", "Organization")
201+
try:
202+
organization = Organization.objects.get(slug=org)
203+
org_uuid = organization.id
204+
except Organization.DoesNotExist:
205+
self.logger.warning(f"Organization '{org}' not found")
206+
return RadiusAccounting.objects.none()
207+
else:
208+
org_uuid = org.id
209+
210+
return RadiusAccounting.objects.filter(
211+
organization_id=org_uuid,
212+
called_station_id__in=unconverted_ids,
213+
stop_time__isnull=True,
214+
)
166215

167216

168217
# monkey patching for openvpn_status begins

openwisp_radius/migrations/0002_initial_openwisp_radius.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ class Migration(migrations.Migration):
248248
max_length=32,
249249
validators=[
250250
django.core.validators.RegexValidator(
251-
re.compile("^[^\\s/\\.]+$"),
251+
re.compile(r"^[^\\s/\.]+$"),
252252
code="invalid",
253253
message=(
254254
"This value must not contain spaces, "
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Generated by Django 5.2.5 on 2025-09-02 06:37
2+
3+
import re
4+
5+
import django.core.validators
6+
from django.db import migrations
7+
8+
import openwisp_utils.fields
9+
import openwisp_utils.utils
10+
11+
12+
class Migration(migrations.Migration):
13+
14+
dependencies = [
15+
("openwisp_radius", "0040_rename_phonetoken_index"),
16+
]
17+
18+
operations = [
19+
migrations.AlterField(
20+
model_name="organizationradiussettings",
21+
name="token",
22+
field=openwisp_utils.fields.KeyField(
23+
default=openwisp_utils.utils.get_random_key,
24+
help_text=None,
25+
max_length=32,
26+
validators=[
27+
django.core.validators.RegexValidator(
28+
re.compile(r"^[^\s/\.]+$"),
29+
code="invalid",
30+
message="This value must not contain spaces, dots or slashes.",
31+
)
32+
],
33+
),
34+
),
35+
]

0 commit comments

Comments
 (0)