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]
### 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
- **Phase-2 profile generation.** `create_esim_profile` now emits a structured
profile package with real 3GPP encodings instead of a loose JSON mock:

View File

@ -1,6 +1,9 @@
import io
import logging
import os
import secrets
import time
from collections import OrderedDict
from datetime import datetime, timezone
from flask import Flask, jsonify, redirect, request, send_file, send_from_directory
@ -11,7 +14,6 @@ from server.database import (
check_rate_limit,
export_all,
get_connection,
get_subscriber_by_id,
init_db,
)
from server.profile_generator import (
@ -35,10 +37,21 @@ logger = logging.getLogger(__name__)
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()
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:
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)
_profile_cache[sub_id] = profile_bytes
token = secrets.token_urlsafe(24)
_cache_profile(token, profile_bytes)
if adapter:
try:
@ -134,7 +148,7 @@ def create_app(config_path: str = CONFIG_PATH) -> Flask:
"id": sub_id,
"imsi": imsi,
"iccid": iccid,
"profile_url": f"/api/profile/{sub_id}",
"profile_url": f"/api/profile/{token}",
}
), 200
@ -144,18 +158,15 @@ def create_app(config_path: str = CONFIG_PATH) -> Flask:
finally:
conn.close()
@app.route("/api/profile/<int:sub_id>")
def get_profile(sub_id: int):
profile_bytes = _profile_cache.get(sub_id)
@app.route("/api/profile/<token>")
def get_profile(token: str):
# 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:
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 no longer cached; re-provision to download"}), 410
import io
return send_file(
io.BytesIO(profile_bytes),
mimetype="application/octet-stream",

View File

@ -9,7 +9,14 @@ logger = logging.getLogger(__name__)
class Open5GSAdapter(CoreAdapter):
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]
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
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:
"""Load and minimally validate a YAML config file."""
if not os.path.exists(path):
raise FileNotFoundError(f"Config file not found: {path}")
with open(path) as 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:
if key not in cfg:
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

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))