-
Notifications
You must be signed in to change notification settings - Fork 141
fix(auth): add startup-generated API key authentication to all routes #278
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
34b200b
29934ae
efa26e9
23dbd7c
670a276
4d90f55
59af316
151c6cf
85c7f0d
c78607c
a524850
b53f11b
7785a91
84ec460
f6d1a8b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| """ | ||
| API key authentication for SecuScan backend. | ||
|
|
||
| A random key is generated at startup and written to <data_dir>/.api_key. | ||
| Clients must supply it via: | ||
| - Authorization: Bearer <key> | ||
| - X-Api-Key: <key> | ||
| """ | ||
|
|
||
| import os | ||
| import secrets | ||
| from pathlib import Path | ||
|
|
||
| from fastapi import Depends, HTTPException, Security, status | ||
| from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer | ||
|
|
||
| _bearer_scheme = HTTPBearer(auto_error=False) | ||
| _api_key_header = APIKeyHeader(name="X-Api-Key", auto_error=False) | ||
|
|
||
| _api_key: str | None = None | ||
|
|
||
|
|
||
| def init_api_key(data_dir: str) -> str: | ||
| """ | ||
| Load the persisted API key, or generate and persist a new one. | ||
|
|
||
| Called once during application startup; the returned key is also stored in | ||
| the module-level ``_api_key`` variable so the FastAPI dependency can reach it. | ||
| """ | ||
| global _api_key | ||
| # Allow operators to redirect the key file via env var (e.g. Docker secrets). | ||
| custom_path = os.environ.get("SECUSCAN_API_KEY_FILE", "").strip() | ||
| key_file = Path(custom_path) if custom_path else Path(data_dir) / ".api_key" | ||
| if key_file.exists(): | ||
| _api_key = key_file.read_text().strip() | ||
| else: | ||
| _api_key = secrets.token_hex(32) | ||
| key_file.parent.mkdir(parents=True, exist_ok=True) | ||
| key_file.write_text(_api_key) | ||
| key_file.chmod(0o600) | ||
| return _api_key | ||
|
|
||
|
|
||
| async def require_api_key( | ||
| bearer: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme), | ||
| x_api_key: str | None = Security(_api_key_header), | ||
| ) -> str: | ||
| """ | ||
| FastAPI dependency — rejects requests that do not carry the correct API key. | ||
|
|
||
| Accepts the key in either: | ||
| - ``Authorization: Bearer <key>`` | ||
| - ``X-Api-Key: <key>`` | ||
| """ | ||
| if _api_key is None: | ||
| raise HTTPException( | ||
| status_code=status.HTTP_503_SERVICE_UNAVAILABLE, | ||
| detail="Authentication service not initialised", | ||
| ) | ||
|
|
||
| candidate: str | None = None | ||
| if bearer is not None: | ||
| candidate = bearer.credentials | ||
| elif x_api_key is not None: | ||
| candidate = x_api_key | ||
|
|
||
| if candidate is None or not secrets.compare_digest(candidate, _api_key): | ||
| raise HTTPException( | ||
| status_code=status.HTTP_401_UNAUTHORIZED, | ||
| detail="Invalid or missing API key", | ||
| headers={"WWW-Authenticate": "Bearer"}, | ||
| ) | ||
|
|
||
| return candidate | ||
|
|
||
|
|
||
| def get_api_key() -> str | None: | ||
| """Return the current API key, or None if not yet initialised.""" | ||
| return _api_key |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -55,6 +55,11 @@ def _serialize_workflow(row: Dict[str, Any], queued_task_ids: Optional[List[str] | |
|
|
||
|
|
||
| def is_filesystem_target(target: str) -> bool: | ||
| """Best-effort detection for path-based targets that should bypass host validation.""" | ||
| # Absolute or relative filesystem roots only — not CIDR notation (e.g. 8.8.8.8/32) | ||
| if target.startswith(("/", "./", "../", "~/")): | ||
| return True | ||
| # Windows drive paths (C:\ or C:/) | ||
| """ | ||
| Return True only for genuine local filesystem paths. | ||
|
|
||
|
|
@@ -121,10 +126,11 @@ def build_report_filename(task: Dict[str, Any], extension: str) -> str: | |
| from .reporting import reporting | ||
| from .vault import VaultCrypto | ||
| from .workflows import scheduler | ||
| from .auth import require_api_key | ||
|
|
||
| from sse_starlette.sse import EventSourceResponse | ||
|
|
||
| router = APIRouter(prefix="/api/v1") | ||
| router = APIRouter(prefix="/api/v1", dependencies=[Depends(require_api_key)]) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Applying Useful? React with 👍 / 👎. |
||
| SSE_RAW_OUTPUT_CHUNK_SIZE = 64 * 1024 | ||
|
|
||
| _EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| # API Authentication | ||
|
|
||
| All `/api/v1/*` routes require a valid API key. The only unauthenticated endpoints | ||
| are `/` (API info) and `/api/v1/health` (health checks). | ||
|
|
||
| ## How the key is generated | ||
|
|
||
| On first startup the backend generates a cryptographically random 64-character hex key, | ||
| writes it to `<data_dir>/.api_key` (mode `0600`), and prints it to the console: | ||
|
|
||
| ``` | ||
| ✓ API key authentication ready (key file: backend/data/.api_key) | ||
| ``` | ||
|
|
||
| On every subsequent start the same key is loaded from the file. | ||
|
|
||
| To rotate the key, delete the file and restart the backend: | ||
|
|
||
| ```bash | ||
| rm backend/data/.api_key | ||
| python -m secuscan # a new key is generated on startup | ||
| ``` | ||
|
|
||
| ## Frontend / UI | ||
|
|
||
| The web UI does **not** fetch the key from the backend. You must configure it | ||
| manually once after starting the backend: | ||
|
|
||
| 1. Read the key from the key file: | ||
| ```bash | ||
| cat backend/data/.api_key | ||
| ``` | ||
| 2. Open the SecuScan UI → **Settings** → **API Key** section. | ||
| 3. Paste the key into the **Backend API Key** field and click **Save**. | ||
|
|
||
| The key is stored in the browser's `localStorage` under `secuscan_api_key` and | ||
| sent automatically on every subsequent API request via the `X-Api-Key` header. | ||
| No server-side session or cookie is involved — only the operator's browser retains | ||
| the key. | ||
|
|
||
| ## External / scripted access | ||
|
|
||
| Read the key from the file and pass it in either of two header formats: | ||
|
|
||
| ```bash | ||
| API_KEY=$(cat backend/data/.api_key) | ||
|
|
||
| # Option A — X-Api-Key header | ||
| curl -H "X-Api-Key: $API_KEY" http://localhost:8000/api/v1/plugins | ||
|
|
||
| # Option B — Bearer token | ||
| curl -H "Authorization: Bearer $API_KEY" http://localhost:8000/api/v1/plugins | ||
| ``` | ||
|
|
||
| ## Environment variable override | ||
|
|
||
| Set `SECUSCAN_API_KEY_FILE` to point to a different key file path if you need to | ||
| store the key outside the default data directory: | ||
|
|
||
| ```bash | ||
| export SECUSCAN_API_KEY_FILE=/run/secrets/secuscan_api_key | ||
| python -m secuscan | ||
| ``` | ||
|
|
||
| ## Unauthenticated endpoints | ||
|
|
||
| | Path | Reason | | ||
| |---|---| | ||
| | `GET /` | API info / root | | ||
| | `GET /api/v1/health` | Health checks and monitoring | | ||
|
|
||
| All other `/api/v1/*` routes require a valid `X-Api-Key` or `Authorization: Bearer` | ||
| header. Requests without a valid key receive `HTTP 401`. | ||
|
|
||
| ## Security considerations | ||
|
|
||
| - The key file is written with mode `0600` so only the process owner can read it. | ||
| - Key comparison uses `secrets.compare_digest` to prevent timing-oracle attacks. | ||
| - There is no unauthenticated endpoint that exposes the key over the network. | ||
| The only way to retrieve the key is to read the file from the filesystem where | ||
| the backend is running — which requires local access to that machine. | ||
| - If the backend is not yet initialised (key file missing and startup not complete), | ||
| protected routes return `HTTP 503` rather than `401` to distinguish between | ||
| an uninitialised service and a bad credential. |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new
is_filesystem_target()logic no longer recognizes relative paths likeartifacts/memdump.raw(it only accepts/,./,../,~/, or Windows-drive prefixes), so these values now fall intovalidate_target()and are rejected as invalid hostnames. This regresses non-code plugins that accept file/directory targets because previously slash-containing paths bypassed host validation; now users must rewrite existing inputs to add./.Useful? React with 👍 / 👎.