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:
parent
1ade8dfc2c
commit
f367103ceb
17
CHANGELOG.md
17
CHANGELOG.md
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
92
tests/test_app.py
Normal 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
85
tests/test_utils.py
Normal 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))
|
||||||
Loading…
Reference in New Issue
Block a user