Harden: capability-token profile URLs, fast core failure, config validation

Security:
- Serve generated profiles at /api/profile/<random-token> instead of
  /api/profile/<sequential-int>. Profiles carry Ki/OPc, so the enumerable id
  scheme allowed anyone on the WiFi to harvest all subscriber keys. Unknown
  tokens return a plain 404 so they can't be probed.

Robustness:
- Open5GS/free5gc adapters use short MongoDB timeouts so an unreachable core
  fails provisioning fast instead of blocking ~30s on the default.
- Profile cache is now a size-capped LRU (OrderedDict) to prevent unbounded
  memory growth.
- load_config rejects empty/non-mapping YAML and validates network.op_key is a
  16-byte hex string.

Tests: +25 (test_app.py API coverage, test_utils.py); 78 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
A ashkan@beigi.net 2026-07-01 06:11:14 +00:00
parent 1ade8dfc2c
commit f367103ceb
6 changed files with 245 additions and 15 deletions

View File

@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Security
- Profile download URLs now use an unguessable capability token
(`secrets.token_urlsafe`) instead of the sequential subscriber id. Profiles
contain Ki/OPc, so the old `/api/profile/<int>` scheme let anyone on the WiFi
enumerate and harvest every subscriber's keys. Unknown tokens return a plain
404 (indistinguishable from evicted) so tokens cannot be probed.
### Hardened
- Core adapters set short MongoDB timeouts (`serverSelectionTimeoutMS` etc.) so
an unreachable core fails provisioning fast instead of hanging ~30s.
- In-memory profile cache is now a size-capped LRU (`_MAX_CACHED_PROFILES`) and
can no longer grow without bound.
- `load_config` rejects an empty/non-mapping YAML file and validates
`network.op_key` is a 16-byte hex string, with clear error messages.
- Added `test_app.py` (API-level: provision, rate-limit, token download,
404 on unknown/enumerated ids) and `test_utils.py` (MAC/IMSI/op_key/config).
### Added ### Added
- **Phase-2 profile generation.** `create_esim_profile` now emits a structured - **Phase-2 profile generation.** `create_esim_profile` now emits a structured
profile package with real 3GPP encodings instead of a loose JSON mock: profile package with real 3GPP encodings instead of a loose JSON mock:

View File

@ -1,6 +1,9 @@
import io
import logging import logging
import os import os
import secrets
import time import time
from collections import OrderedDict
from datetime import datetime, timezone from datetime import datetime, timezone
from flask import Flask, jsonify, redirect, request, send_file, send_from_directory from flask import Flask, jsonify, redirect, request, send_file, send_from_directory
@ -11,7 +14,6 @@ from server.database import (
check_rate_limit, check_rate_limit,
export_all, export_all,
get_connection, get_connection,
get_subscriber_by_id,
init_db, init_db,
) )
from server.profile_generator import ( from server.profile_generator import (
@ -35,10 +37,21 @@ logger = logging.getLogger(__name__)
CONFIG_PATH = os.environ.get("CONFIG_PATH", "config.yaml") CONFIG_PATH = os.environ.get("CONFIG_PATH", "config.yaml")
_profile_cache: dict[int, bytes] = {} # Generated profiles are held in memory, keyed by an unguessable download token
# (never by the sequential subscriber id — the profile body contains Ki/OPc).
# The cache is size-capped so it cannot grow without bound.
_MAX_CACHED_PROFILES = 256
_profile_cache: "OrderedDict[str, bytes]" = OrderedDict()
_start_time = time.time() _start_time = time.time()
def _cache_profile(token: str, data: bytes) -> None:
_profile_cache[token] = data
_profile_cache.move_to_end(token)
while len(_profile_cache) > _MAX_CACHED_PROFILES:
_profile_cache.popitem(last=False)
def create_app(config_path: str = CONFIG_PATH) -> Flask: def create_app(config_path: str = CONFIG_PATH) -> Flask:
cfg = load_config(config_path) cfg = load_config(config_path)
@ -120,7 +133,8 @@ def create_app(config_path: str = CONFIG_PATH) -> Flask:
) )
sub_id = add_subscriber(conn, imsi, iccid, ki_hex, opc_hex, mac) sub_id = add_subscriber(conn, imsi, iccid, ki_hex, opc_hex, mac)
_profile_cache[sub_id] = profile_bytes token = secrets.token_urlsafe(24)
_cache_profile(token, profile_bytes)
if adapter: if adapter:
try: try:
@ -134,7 +148,7 @@ def create_app(config_path: str = CONFIG_PATH) -> Flask:
"id": sub_id, "id": sub_id,
"imsi": imsi, "imsi": imsi,
"iccid": iccid, "iccid": iccid,
"profile_url": f"/api/profile/{sub_id}", "profile_url": f"/api/profile/{token}",
} }
), 200 ), 200
@ -144,18 +158,15 @@ def create_app(config_path: str = CONFIG_PATH) -> Flask:
finally: finally:
conn.close() conn.close()
@app.route("/api/profile/<int:sub_id>") @app.route("/api/profile/<token>")
def get_profile(sub_id: int): def get_profile(token: str):
profile_bytes = _profile_cache.get(sub_id) # Capability URL: only the holder of the random token can download the
# profile. Never distinguish "wrong token" from "expired/evicted" —
# both are a plain 404 so tokens can't be probed.
profile_bytes = _profile_cache.get(token)
if profile_bytes is None: if profile_bytes is None:
conn = get_connection(db_path)
sub = get_subscriber_by_id(conn, sub_id)
conn.close()
if sub is None:
return jsonify({"error": "Profile not found"}), 404 return jsonify({"error": "Profile not found"}), 404
return jsonify({"error": "Profile no longer cached; re-provision to download"}), 410
import io
return send_file( return send_file(
io.BytesIO(profile_bytes), io.BytesIO(profile_bytes),
mimetype="application/octet-stream", mimetype="application/octet-stream",

View File

@ -9,7 +9,14 @@ logger = logging.getLogger(__name__)
class Open5GSAdapter(CoreAdapter): class Open5GSAdapter(CoreAdapter):
def __init__(self, mongodb_uri: str, database_name: str = "open5gs"): def __init__(self, mongodb_uri: str, database_name: str = "open5gs"):
self.client = MongoClient(mongodb_uri) # Short timeouts so a down/unreachable core fails the provision fast
# instead of blocking the request for pymongo's 30s default.
self.client = MongoClient(
mongodb_uri,
serverSelectionTimeoutMS=3000,
connectTimeoutMS=3000,
socketTimeoutMS=3000,
)
self.db = self.client[database_name] self.db = self.client[database_name]
def add_subscriber(self, imsi: str, ki: str, opc: str) -> None: def add_subscriber(self, imsi: str, ki: str, opc: str) -> None:

View File

@ -34,15 +34,33 @@ def validate_imsi(imsi: str) -> bool:
return bool(imsi) and imsi.isdigit() and len(imsi) == 15 return bool(imsi) and imsi.isdigit() and len(imsi) == 15
def validate_op_key(op_key: str) -> bool:
"""Return True if op_key is a 16-byte (32 hex digit) hex string."""
if not isinstance(op_key, str) or len(op_key) != 32:
return False
try:
bytes.fromhex(op_key)
except ValueError:
return False
return True
def load_config(path: str) -> dict: def load_config(path: str) -> dict:
"""Load and minimally validate a YAML config file.""" """Load and minimally validate a YAML config file."""
if not os.path.exists(path): if not os.path.exists(path):
raise FileNotFoundError(f"Config file not found: {path}") raise FileNotFoundError(f"Config file not found: {path}")
with open(path) as f: with open(path) as f:
cfg = yaml.safe_load(f) cfg = yaml.safe_load(f)
if not isinstance(cfg, dict):
raise ValueError(f"Config file is empty or not a mapping: {path}")
for key in _REQUIRED_CONFIG_KEYS: for key in _REQUIRED_CONFIG_KEYS:
if key not in cfg: if key not in cfg:
raise ValueError(f"Missing required config section: '{key}'") raise ValueError(f"Missing required config section: '{key}'")
op_key = cfg["network"].get("op_key")
if not validate_op_key(op_key):
raise ValueError(
"network.op_key must be a 16-byte hex string (32 hex digits)"
)
return cfg return cfg

92
tests/test_app.py Normal file
View File

@ -0,0 +1,92 @@
import json
import pytest
import yaml
from server.app import create_app
def _write_config(tmp_path):
cfg = {
"network": {"mcc": "302", "mnc": "720", "plmn": "302720",
"op_key": "63bfa50ee6523365ff14c1f45f88737d"},
"wifi": {"ssid": "Emergency-5G", "channel": 6, "ip": "192.168.4.1",
"subnet": "192.168.4.0/24", "dhcp_range": "192.168.4.10,192.168.4.250"},
"server": {"host": "0.0.0.0", "port": 5000, "debug": False},
"database": {"path": str(tmp_path / "subscribers.db")},
"rate_limiting": {"max_profiles_per_mac": 1, "window_hours": 1},
"core": {"type": "none", "mongodb_uri": "mongodb://localhost:27017",
"database_name": "open5gs"},
"profile": {"carrier_name": "Ad-Hoc Network", "profile_name": "Emergency 5G",
"apn": "internet", "smdp_address": "esim.local"},
}
path = tmp_path / "config.yaml"
path.write_text(yaml.safe_dump(cfg))
return str(path)
@pytest.fixture
def client(tmp_path):
app = create_app(_write_config(tmp_path))
app.config.update(TESTING=True)
return app.test_client()
class TestRoutes:
def test_index_redirects_to_activate(self, client):
r = client.get("/")
assert r.status_code == 302
assert "/activate" in r.headers["Location"]
def test_status_ok(self, client):
data = client.get("/api/status").get_json()
assert data["status"] == "ok"
assert data["core_adapter"] == "none"
def test_export_empty(self, client):
data = client.get("/api/export").get_json()
assert data["count"] == 0
assert data["subscribers"] == []
class TestProvision:
def test_provision_success(self, client):
r = client.post("/api/provision", json={"mac_address": "aa:bb:cc:11:22:33"})
assert r.status_code == 200
body = r.get_json()
assert len(body["imsi"]) == 15
assert body["profile_url"].startswith("/api/profile/")
def test_provision_missing_mac(self, client):
# No body and (in tests) no resolvable ARP entry -> 400
r = client.post("/api/provision", json={})
assert r.status_code == 400
def test_provision_invalid_mac(self, client):
r = client.post("/api/provision", json={"mac_address": "not-a-mac"})
assert r.status_code == 400
def test_rate_limited_second_request(self, client):
client.post("/api/provision", json={"mac_address": "de:ad:be:ef:00:01"})
r = client.post("/api/provision", json={"mac_address": "de:ad:be:ef:00:01"})
assert r.status_code == 429
class TestProfileDownload:
def test_download_with_token(self, client):
r = client.post("/api/provision", json={"mac_address": "aa:bb:cc:44:55:66"})
url = r.get_json()["profile_url"]
d = client.get(url)
assert d.status_code == 200
profile = json.loads(d.data)
assert profile["type"] == "profile-package-v2"
def test_unknown_token_is_404(self, client):
assert client.get("/api/profile/does-not-exist").status_code == 404
def test_sequential_id_not_downloadable(self, client):
# The old enumerable /api/profile/<int> scheme must be gone: the row id
# is not a valid download token.
r = client.post("/api/provision", json={"mac_address": "aa:bb:cc:77:88:99"})
sub_id = r.get_json()["id"]
assert client.get(f"/api/profile/{sub_id}").status_code == 404

85
tests/test_utils.py Normal file
View File

@ -0,0 +1,85 @@
import pytest
from server.utils import (
load_config,
normalize_mac,
validate_imsi,
validate_mac,
validate_op_key,
)
class TestMac:
def test_valid(self):
assert validate_mac("aa:bb:cc:dd:ee:ff")
def test_invalid(self):
assert not validate_mac("nope")
assert not validate_mac("")
def test_normalize_dashes_and_case(self):
assert normalize_mac("AA-BB-CC-DD-EE-FF") == "aa:bb:cc:dd:ee:ff"
def test_normalize_no_separator(self):
assert normalize_mac("aabbccddeeff") == "aa:bb:cc:dd:ee:ff"
class TestImsi:
def test_valid(self):
assert validate_imsi("302720000000001")
def test_wrong_length(self):
assert not validate_imsi("30272000000000")
def test_non_digit(self):
assert not validate_imsi("30272000000000x")
class TestOpKey:
def test_valid(self):
assert validate_op_key("63bfa50ee6523365ff14c1f45f88737d")
def test_wrong_length(self):
assert not validate_op_key("abcd")
def test_non_hex(self):
assert not validate_op_key("zz" * 16)
class TestLoadConfig:
def _base(self):
return (
"network: {mcc: '302', mnc: '720', op_key: '63bfa50ee6523365ff14c1f45f88737d'}\n"
"server: {host: '0.0.0.0', port: 5000}\n"
"database: {path: '/tmp/x.db'}\n"
"rate_limiting: {window_hours: 1}\n"
"profile: {profile_name: 'P', carrier_name: 'C'}\n"
)
def test_valid(self, tmp_path):
p = tmp_path / "c.yaml"
p.write_text(self._base())
cfg = load_config(str(p))
assert cfg["network"]["mcc"] == "302"
def test_missing_file(self):
with pytest.raises(FileNotFoundError):
load_config("/no/such/config.yaml")
def test_empty_file(self, tmp_path):
p = tmp_path / "empty.yaml"
p.write_text("")
with pytest.raises(ValueError, match="empty or not a mapping"):
load_config(str(p))
def test_missing_section(self, tmp_path):
p = tmp_path / "c.yaml"
p.write_text("network: {op_key: '63bfa50ee6523365ff14c1f45f88737d'}\n")
with pytest.raises(ValueError, match="Missing required config section"):
load_config(str(p))
def test_bad_op_key(self, tmp_path):
p = tmp_path / "c.yaml"
p.write_text(self._base().replace("63bfa50ee6523365ff14c1f45f88737d", "abcd"))
with pytest.raises(ValueError, match="op_key"):
load_config(str(p))