Skip to content

Commit 183c688

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

15 files changed

Lines changed: 542 additions & 237 deletions

openwisp_radius/api/freeradius_views.py

Lines changed: 2 additions & 1 deletion
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,7 +373,7 @@ 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+
called_station_id=sanitize_mac_address(called_station_id),
376377
calling_station_id=calling_station_id,
377378
)
378379
open_sessions = open_sessions.count()

openwisp_radius/api/serializers.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636
from .. import settings as app_settings
3737
from ..base.forms import PasswordResetForm
38+
from ..base.models import sanitize_mac_address
3839
from ..counters.exceptions import SkipCheck
3940
from ..registration import REGISTRATION_METHOD_CHOICES
4041
from ..utils import (
@@ -271,7 +272,11 @@ def _check_called_station_id(self, instance, acct_data):
271272
do not overwrite it back to unconverted ID during interim updates
272273
"""
273274
ids = app_settings.CALLED_STATION_IDS
274-
if not ids or instance.called_station_id == acct_data["called_station_id"]:
275+
called_station_id = acct_data.get("called_station_id")
276+
if not called_station_id:
277+
return acct_data
278+
sanitized = sanitize_mac_address(called_station_id)
279+
if not ids or instance.called_station_id == sanitized:
275280
return acct_data
276281
try:
277282
organization = acct_data["organization"]
@@ -285,7 +290,7 @@ def _check_called_station_id(self, instance, acct_data):
285290
unconverted_ids = ids.get(str(organization.id), {}).get(
286291
"unconverted_ids", []
287292
) + ids.get(organization.slug, {}).get("unconverted_ids", [])
288-
if acct_data["called_station_id"] in unconverted_ids:
293+
if sanitized in unconverted_ids or called_station_id in unconverted_ids:
289294
acct_data["called_station_id"] = instance.called_station_id
290295
except Exception:
291296
logger.exception("Got exception in _check_called_station_id")

openwisp_radius/base/models.py

Lines changed: 36 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,40 @@ 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])[0-9A-Fa-f]{12}(?![0-9A-Fa-f])",
205+
]
206+
for pattern in mac_patterns:
207+
match = re.search(pattern, mac)
208+
if match:
209+
try:
210+
eui = EUI(match.group(0))
211+
return ":".join([f"{x:02x}" for x in eui.words]).lower()
212+
except (AddrFormatError, ValueError, TypeError):
213+
continue
214+
return mac
215+
216+
226217
class AutoUsernameMixin(object):
227218
def clean(self):
228219
"""
@@ -624,8 +615,9 @@ def _close_stale_sessions_on_nas_boot(cls, called_station_id):
624615
"""
625616
if not called_station_id:
626617
return 0
618+
sanitized_called_station_id = sanitize_mac_address(called_station_id)
627619
stale_sessions = cls.objects.filter(
628-
called_station_id=called_station_id,
620+
called_station_id=sanitized_called_station_id,
629621
stop_time__isnull=True,
630622
)
631623
closed_count = stale_sessions.update(

openwisp_radius/management/commands/base/convert_called_station_id.py

Lines changed: 103 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,42 @@
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-
15-
RE_VIRTUAL_ADDR_MAC = re.compile("^{0}:{0}:{0}:{0}:{0}:{0}".format("[a-f0-9]{2}"), re.I)
1615
TELNET_CONNECTION_TIMEOUT = 30 # In seconds
1716

18-
1917
RadiusAccounting = load_model("RadiusAccounting")
2018

19+
logger = logging.getLogger(__name__)
20+
2121

2222
class BaseConvertCalledStationIdCommand(BaseCommand):
23+
logger = logger
24+
25+
def _search_mac_address(self, common_name):
26+
"""
27+
Search for a MAC address in the given common_name string.
28+
Supports colon-separated, dash-separated, dot-separated,
29+
and unseparated MAC formats.
30+
"""
31+
mac_patterns = [
32+
r"([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}",
33+
r"([0-9A-Fa-f]{4}\.){2}[0-9A-Fa-f]{4}",
34+
r"(?<![0-9A-Fa-f])[0-9A-Fa-f]{12}(?![0-9A-Fa-f])",
35+
]
36+
for pattern in mac_patterns:
37+
match = re.search(pattern, common_name)
38+
if match:
39+
return match[0]
40+
raise IndexError(f"No MAC address found in '{common_name}'")
41+
2342
help = "Correct Called Station IDs of Radius Sessions"
2443

2544
def _get_raw_management_info(self, host, port, password):
@@ -42,29 +61,29 @@ def _get_openvpn_routing_info(self, host, port=7505, password=None):
4261
try:
4362
raw_info = self._get_raw_management_info(host, port, password)
4463
except ConnectionRefusedError:
45-
logger.warning(
64+
BaseConvertCalledStationIdCommand.logger.warning(
4665
"Unable to establish telnet connection to "
4766
f"{host} on {port}. Skipping!"
4867
)
4968
return {}
5069
except (OSError, TimeoutError, EOFError) as error:
51-
logger.warning(
70+
BaseConvertCalledStationIdCommand.logger.warning(
5271
f"Error encountered while connecting to {host}:{port}: {error}. "
5372
"Skipping!"
5473
)
5574
return {}
5675
except Exception:
57-
logger.warning(
76+
BaseConvertCalledStationIdCommand.logger.warning(
5877
f"Error encountered while connecting to {host}:{port}. Skipping!"
5978
)
6079
return {}
6180
try:
6281
parsed_info = openvpn_status.parse_status(raw_info)
6382
return parsed_info.routing_table
6483
except openvpn_status.ParsingError as error:
65-
logger.warning(
84+
BaseConvertCalledStationIdCommand.logger.warning(
6685
"Unable to parse information received from "
67-
f"{host}:{port}. ParsingError: {error}. Skipping!",
86+
f"{host}:{port}. ParsingError: {error}. Skipping!"
6887
)
6988
return {}
7089

@@ -74,7 +93,7 @@ def _get_radius_session(self, unique_id):
7493
unique_id=unique_id
7594
)
7695
except RadiusAccounting.DoesNotExist:
77-
logger.warning(
96+
BaseConvertCalledStationIdCommand.logger.warning(
7897
f'RadiusAccount object with unique_id "{unique_id}" does not exist.'
7998
)
8099

@@ -88,24 +107,11 @@ def _get_called_station_setting(self, radius_session):
88107
# but will removed in future versions
89108
return {org_id: app_settings.CALLED_STATION_IDS[organization.slug]}
90109
except KeyError:
91-
logger.error(
110+
BaseConvertCalledStationIdCommand.logger.error(
92111
"OPENWISP_RADIUS_CALLED_STATION_IDS does not contain setting "
93112
f'for "{radius_session.organization.name}" organization'
94113
)
95114

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-
109115
def add_arguments(self, parser):
110116
parser.add_argument("--unique_id", action="store", type=str, default="")
111117

@@ -128,15 +134,24 @@ def handle(self, *args, **options):
128134
for org, config in called_station_id_setting.items():
129135
routing_dict = {}
130136
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-
)
137+
raw_routing = self.__class__._get_openvpn_routing_info(
138+
self,
139+
openvpn_config["host"],
140+
openvpn_config.get("port", 7505),
141+
openvpn_config.get("password", None),
137142
)
143+
normalized_routing = {}
144+
for k, v in raw_routing.items():
145+
try:
146+
norm_key = str(EUI(k, dialect=mac_unix)).lower()
147+
except Exception:
148+
norm_key = k.lower()
149+
normalized_routing[norm_key] = v
150+
routing_dict.update(normalized_routing)
138151
if not routing_dict:
139-
logger.info(f'No routing information found for "{org}" organization')
152+
BaseConvertCalledStationIdCommand.logger.info(
153+
f'No routing information found for "{org}" organization'
154+
)
140155
continue
141156

142157
if unique_id:
@@ -145,24 +160,68 @@ def handle(self, *args, **options):
145160
qs = self._get_unconverted_sessions(org, config["unconverted_ids"])
146161
for radius_session in qs:
147162
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 "
163+
lookup_key = str(
164+
EUI(radius_session.calling_station_id, dialect=mac_unix)
165+
).lower()
166+
except (AddrFormatError, ValueError, TypeError):
167+
BaseConvertCalledStationIdCommand.logger.warning(
168+
f"Invalid calling_station_id for session "
157169
f"{radius_session.session_id}. Skipping!"
158170
)
171+
continue
172+
if lookup_key not in routing_dict:
173+
174+
def _strip_leading_zeros(k):
175+
parts = k.split(":")
176+
return ":".join([p.lstrip("0") or "0" for p in parts])
177+
178+
alt_key = _strip_leading_zeros(lookup_key)
179+
if alt_key in routing_dict:
180+
routing_dict[lookup_key] = routing_dict[alt_key]
181+
else:
182+
BaseConvertCalledStationIdCommand.logger.warning(
183+
"Failed to find routing information for "
184+
f"{radius_session.session_id}. Skipping!"
185+
)
186+
continue
187+
188+
common_name = routing_dict[lookup_key].common_name
189+
190+
try:
191+
mac_address = self._search_mac_address(common_name)
159192
except (TypeError, IndexError):
160-
logger.warning(
193+
BaseConvertCalledStationIdCommand.logger.warning(
161194
f'Failed to find a MAC address in "{common_name}". '
162195
f"Skipping {radius_session.session_id}!"
163196
)
164-
else:
165-
radius_session.save()
197+
continue
198+
radius_session.called_station_id = sanitize_mac_address(mac_address)
199+
radius_session.save()
200+
201+
def _get_unconverted_sessions(self, org, unconverted_ids):
202+
"""
203+
Get unconverted sessions for the given organization and unconverted IDs.
204+
"""
205+
normalized_ids = [sanitize_mac_address(uid) for uid in unconverted_ids]
206+
if isinstance(org, str):
207+
try:
208+
org_uuid = UUID(org)
209+
except ValueError:
210+
Organization = swapper.load_model("openwisp_users", "Organization")
211+
try:
212+
organization = Organization.objects.get(slug=org)
213+
org_uuid = organization.id
214+
except Organization.DoesNotExist:
215+
self.logger.warning(f"Organization '{org}' not found")
216+
return RadiusAccounting.objects.none()
217+
else:
218+
org_uuid = org.id
219+
220+
return RadiusAccounting.objects.filter(
221+
organization_id=org_uuid,
222+
called_station_id__in=normalized_ids,
223+
stop_time__isnull=True,
224+
)
166225

167226

168227
# 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, "

0 commit comments

Comments
 (0)