diff --git a/CHANGELOG.md b/CHANGELOG.md index 139c721..4432e2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Security +- `/api/export` (dumps every subscriber's Ki/OPc) now requires an admin token + (`server.admin_token`, sent as `X-Admin-Token`, constant-time compared) and + is disabled by default. Previously any WiFi client could pull all credentials + unauthenticated. - 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/` scheme let anyone on the WiFi diff --git a/config.yaml.example b/config.yaml.example index 57fd29f..ca60d05 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -15,6 +15,7 @@ server: host: "0.0.0.0" port: 5000 debug: false + admin_token: "" # set a secret to enable /api/export (sent as X-Admin-Token); empty = export disabled database: path: "/opt/esim-provisioning/data/subscribers.db" diff --git a/server/app.py b/server/app.py index dd0282a..2b3221a 100644 --- a/server/app.py +++ b/server/app.py @@ -93,6 +93,9 @@ def create_app(config_path: str = CONFIG_PATH) -> Flask: rl_cfg = cfg["rate_limiting"] window_hours = rl_cfg["window_hours"] + # Optional admin token guarding /api/export; unset means export is disabled. + admin_token = cfg.get("server", {}).get("admin_token") or "" + # ------------------------------------------------------------------ # Routes # ------------------------------------------------------------------ @@ -194,6 +197,14 @@ def create_app(config_path: str = CONFIG_PATH) -> Flask: @app.route("/api/export") def export(): + # This endpoint dumps every subscriber's Ki/OPc, so it is gated behind + # an admin token and defaults closed (disabled unless configured). + if not admin_token: + return jsonify({"error": "Export disabled (server.admin_token not set)"}), 403 + provided = request.headers.get("X-Admin-Token", "") + if not secrets.compare_digest(provided, admin_token): + return jsonify({"error": "Unauthorized"}), 401 + conn = get_connection(db_path) try: subscribers = export_all(conn) diff --git a/tests/test_app.py b/tests/test_app.py index 6c31692..ee511f9 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -6,13 +6,14 @@ import yaml from server.app import create_app -def _write_config(tmp_path): +def _write_config(tmp_path, admin_token=""): 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}, + "server": {"host": "0.0.0.0", "port": 5000, "debug": False, + "admin_token": admin_token}, "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", @@ -32,6 +33,13 @@ def client(tmp_path): return app.test_client() +@pytest.fixture +def admin_client(tmp_path): + app = create_app(_write_config(tmp_path, admin_token="s3cret-token")) + app.config.update(TESTING=True) + return app.test_client() + + class TestRoutes: def test_index_redirects_to_activate(self, client): r = client.get("/") @@ -43,8 +51,20 @@ class TestRoutes: assert data["status"] == "ok" assert data["core_adapter"] == "none" - def test_export_empty(self, client): - data = client.get("/api/export").get_json() + def test_export_disabled_without_admin_token(self, client): + # No server.admin_token configured -> export is disabled. + assert client.get("/api/export").status_code == 403 + + def test_export_requires_matching_token(self, admin_client): + assert admin_client.get("/api/export").status_code == 401 + assert admin_client.get( + "/api/export", headers={"X-Admin-Token": "wrong"} + ).status_code == 401 + + def test_export_with_valid_token(self, admin_client): + r = admin_client.get("/api/export", headers={"X-Admin-Token": "s3cret-token"}) + assert r.status_code == 200 + data = r.get_json() assert data["count"] == 0 assert data["subscribers"] == []