From 755de7f231e1daabd3ae74219f3a2b1fbbe15112 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Fri, 6 Mar 2026 18:29:12 +0100 Subject: [PATCH 1/8] feat: add password configuration for dashboard access --- config.yaml | 5 +++++ src/config.py | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/config.yaml b/config.yaml index dd61720..cef8e52 100644 --- a/config.yaml +++ b/config.yaml @@ -25,6 +25,11 @@ dashboard: # secret_path: super-secret-dashboard-path secret_path: null + # Password for accessing protected dashboard panels. + # If null, a random password will be generated and printed in the logs. + # Can also be set via KRAWL_DASHBOARD_PASSWORD env var. + password: null + backups: path: "backups" cron: "*/30 * * * *" diff --git a/src/config.py b/src/config.py index cb46bf6..0d6d648 100644 --- a/src/config.py +++ b/src/config.py @@ -28,6 +28,7 @@ class Config: canary_token_url: Optional[str] = None canary_token_tries: int = 10 dashboard_secret_path: str = None + dashboard_password: Optional[str] = None probability_error_codes: int = 0 # Percentage (0-100) # Crawl limiting settings - for legitimate vs malicious crawlers @@ -176,6 +177,11 @@ class Config: if dashboard_path[:1] != "/": dashboard_path = f"/{dashboard_path}" + # Handle dashboard_password - auto-generate if null/not set + dashboard_password = dashboard.get("password") + if dashboard_password is None: + dashboard_password = os.urandom(25).hex() + return cls( port=server.get("port", 5000), delay=server.get("delay", 100), @@ -196,6 +202,7 @@ class Config: canary_token_url=canary.get("token_url"), canary_token_tries=canary.get("token_tries", 10), dashboard_secret_path=dashboard_path, + dashboard_password=dashboard_password, probability_error_codes=behavior.get("probability_error_codes", 0), exports_path=exports.get("path", "exports"), backups_path=backups.get("path", "backups"), From 18536f0706876450b4c79ad74af847e5b0d20c5f Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Fri, 6 Mar 2026 22:19:59 +0100 Subject: [PATCH 2/8] feat: implement FastAPI authentication with password protection for admin panel --- src/app.py | 6 +- src/config.py | 4 ++ src/routes/api.py | 56 ++++++++++++++++- src/routes/htmx.py | 26 ++++++++ src/templates/jinja2/dashboard/index.html | 18 ++++++ .../dashboard/partials/admin_panel.html | 4 ++ src/templates/static/css/dashboard.css | 17 ++++++ src/templates/static/js/dashboard.js | 61 ++++++++++++++++++- 8 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 src/templates/jinja2/dashboard/partials/admin_panel.html diff --git a/src/app.py b/src/app.py index 2b2df92..87904b8 100644 --- a/src/app.py +++ b/src/app.py @@ -72,11 +72,15 @@ async def lifespan(app: FastAPI): tasks_master = get_tasksmaster() tasks_master.run_scheduled_tasks() + password_line = "" + if config.dashboard_password_generated: + password_line = f"\n\nDASHBOARD PASSWORD (auto-generated)\n{config.dashboard_password}" + banner = f""" ============================================================ DASHBOARD AVAILABLE AT -{config.dashboard_secret_path} +{config.dashboard_secret_path}{password_line} ============================================================ """ app_logger.info(banner) diff --git a/src/config.py b/src/config.py index 0d6d648..f43b390 100644 --- a/src/config.py +++ b/src/config.py @@ -29,6 +29,7 @@ class Config: canary_token_tries: int = 10 dashboard_secret_path: str = None dashboard_password: Optional[str] = None + dashboard_password_generated: bool = False probability_error_codes: int = 0 # Percentage (0-100) # Crawl limiting settings - for legitimate vs malicious crawlers @@ -179,8 +180,10 @@ class Config: # Handle dashboard_password - auto-generate if null/not set dashboard_password = dashboard.get("password") + dashboard_password_generated = False if dashboard_password is None: dashboard_password = os.urandom(25).hex() + dashboard_password_generated = True return cls( port=server.get("port", 5000), @@ -203,6 +206,7 @@ class Config: canary_token_tries=canary.get("token_tries", 10), dashboard_secret_path=dashboard_path, dashboard_password=dashboard_password, + dashboard_password_generated=dashboard_password_generated, probability_error_codes=behavior.get("probability_error_codes", 0), exports_path=exports.get("path", "exports"), backups_path=backups.get("path", "backups"), diff --git a/src/routes/api.py b/src/routes/api.py index d94b3b6..9ef8f0f 100644 --- a/src/routes/api.py +++ b/src/routes/api.py @@ -7,13 +7,19 @@ All endpoints are prefixed with the secret dashboard path. """ import os +import secrets +import hmac -from fastapi import APIRouter, Request, Response, Query +from fastapi import APIRouter, Request, Response, Query, Cookie from fastapi.responses import JSONResponse, PlainTextResponse +from pydantic import BaseModel from dependencies import get_db from logger import get_app_logger +# Server-side session token store (valid tokens for authenticated sessions) +_auth_tokens: set = set() + router = APIRouter() @@ -26,6 +32,54 @@ def _no_cache_headers() -> dict: } +class AuthRequest(BaseModel): + password: str + + +def verify_auth(request: Request) -> bool: + """Check if the request has a valid auth session cookie.""" + token = request.cookies.get("krawl_auth") + return token is not None and token in _auth_tokens + + +@router.post("/api/auth") +async def authenticate(request: Request, body: AuthRequest): + config = request.app.state.config + if hmac.compare_digest(body.password, config.dashboard_password): + token = secrets.token_hex(32) + _auth_tokens.add(token) + response = JSONResponse(content={"authenticated": True}) + response.set_cookie( + key="krawl_auth", + value=token, + httponly=True, + samesite="strict", + ) + return response + return JSONResponse( + content={"authenticated": False, "error": "Invalid password"}, + status_code=401, + ) + + +@router.post("/api/auth/logout") +async def logout(request: Request): + token = request.cookies.get("krawl_auth") + if token and token in _auth_tokens: + _auth_tokens.discard(token) + response = JSONResponse(content={"authenticated": False}) + response.delete_cookie(key="krawl_auth") + return response + + +@router.get("/api/auth/check") +async def auth_check(request: Request): + """Check if the current session is authenticated.""" + if verify_auth(request): + return JSONResponse(content={"authenticated": True}) + return JSONResponse(content={"authenticated": False}, status_code=401) + + @router.get("/api/all-ip-stats") async def all_ip_stats(request: Request): db = get_db() diff --git a/src/routes/htmx.py b/src/routes/htmx.py index 549f044..a793c87 100644 --- a/src/routes/htmx.py +++ b/src/routes/htmx.py @@ -6,8 +6,10 @@ Server-rendered HTML partials for table pagination, sorting, IP details, and sea """ from fastapi import APIRouter, Request, Response, Query +from fastapi.responses import HTMLResponse from dependencies import get_db, get_templates +from routes.api import verify_auth router = APIRouter() @@ -408,3 +410,27 @@ async def htmx_search( "pagination": result["pagination"], }, ) + + +# ── Protected Admin Panel ──────────────────────────────────────────── + + +@router.get("/htmx/admin") +async def htmx_admin(request: Request): + if not verify_auth(request): + return HTMLResponse( + '
' + '

Nice try bozo

' + '
' + 'Diddy' + '
', + status_code=200, + ) + templates = get_templates() + return templates.TemplateResponse( + "dashboard/partials/admin_panel.html", + { + "request": request, + "dashboard_path": _dashboard_path(request), + }, + ) diff --git a/src/templates/jinja2/dashboard/index.html b/src/templates/jinja2/dashboard/index.html index fef46c6..8fba3e7 100644 --- a/src/templates/jinja2/dashboard/index.html +++ b/src/templates/jinja2/dashboard/index.html @@ -59,6 +59,19 @@ IP Insight + Admin + {# Lock icon (not authenticated) #} + + + + + + {# Logout icon (authenticated) #} + + + + + {# ==================== OVERVIEW TAB ==================== #} @@ -184,6 +197,11 @@ + {# ==================== ADMIN TAB (protected, loaded via HTMX with server-side auth) ==================== #} +
+
+
+ {# Raw request modal - Alpine.js #} {% include "dashboard/partials/raw_request_modal.html" %} diff --git a/src/templates/jinja2/dashboard/partials/admin_panel.html b/src/templates/jinja2/dashboard/partials/admin_panel.html new file mode 100644 index 0000000..f70bb65 --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/admin_panel.html @@ -0,0 +1,4 @@ +
+

Admin Panel

+

This is a protected panel. More features coming soon.

+
diff --git a/src/templates/static/css/dashboard.css b/src/templates/static/css/dashboard.css index 7297e89..98d1472 100644 --- a/src/templates/static/css/dashboard.css +++ b/src/templates/static/css/dashboard.css @@ -854,6 +854,23 @@ tbody { color: #484f58; background: transparent; } +.tab-right { + margin-left: auto; +} +.tab-lock-btn { + display: flex; + align-items: center; + padding: 12px 16px; + color: #8b949e; +} +.tab-lock-btn:hover { + color: #f0883e; + background: #1c2128; +} +.tab-lock-btn svg { + width: 16px; + height: 16px; +} .tab-content { display: none; } diff --git a/src/templates/static/js/dashboard.js b/src/templates/static/js/dashboard.js index 329e1e4..c1b12d5 100644 --- a/src/templates/static/js/dashboard.js +++ b/src/templates/static/js/dashboard.js @@ -20,7 +20,16 @@ document.addEventListener('alpine:init', () => { // IP Insight state insightIp: null, - init() { + // Auth state (UI only — actual security enforced server-side via cookie) + authenticated: false, + + async init() { + // Check if already authenticated (cookie-based) + try { + const resp = await fetch(`${this.dashboardPath}/api/auth/check`, { credentials: 'same-origin' }); + if (resp.ok) this.authenticated = true; + } catch {} + // Handle hash-based tab routing const hash = window.location.hash.slice(1); if (hash === 'ip-stats' || hash === 'attacks') { @@ -32,8 +41,9 @@ document.addEventListener('alpine:init', () => { const h = window.location.hash.slice(1); if (h === 'ip-stats' || h === 'attacks') { this.switchToAttacks(); + } else if (h === 'admin') { + if (this.authenticated) this.switchToAdmin(); } else if (h !== 'ip-insight') { - // Don't switch away from ip-insight via hash if already there if (this.tab !== 'ip-insight') { this.switchToOverview(); } @@ -61,6 +71,53 @@ document.addEventListener('alpine:init', () => { window.location.hash = '#overview'; }, + switchToAdmin() { + if (!this.authenticated) return; + this.tab = 'admin'; + window.location.hash = '#admin'; + this.$nextTick(() => { + const container = document.getElementById('admin-htmx-container'); + if (container && typeof htmx !== 'undefined') { + htmx.ajax('GET', `${this.dashboardPath}/htmx/admin`, { + target: '#admin-htmx-container', + swap: 'innerHTML' + }); + } + }); + }, + + async logout() { + try { + await fetch(`${this.dashboardPath}/api/auth/logout`, { + method: 'POST', + credentials: 'same-origin', + }); + } catch {} + this.authenticated = false; + if (this.tab === 'admin') this.switchToOverview(); + }, + + async promptAuth() { + const password = prompt('Enter dashboard password:'); + if (!password) return; + try { + const resp = await fetch(`${this.dashboardPath}/api/auth`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ password }), + }); + if (resp.ok) { + this.authenticated = true; + this.switchToAdmin(); + } else { + alert('Invalid password'); + } + } catch { + alert('Authentication failed'); + } + }, + switchToIpInsight() { // Only allow switching if an IP is selected if (!this.insightIp) return; From 5553559ea631826ebf35b358129715cb5631b198 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Fri, 6 Mar 2026 22:40:16 +0100 Subject: [PATCH 3/8] feat: add dashboard password configuration to README and docker-compose --- README.md | 7 ++++++- docker-compose.yaml | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6bc2ba5..87a9af9 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ docker run -d \ -e KRAWL_PORT=5000 \ -e KRAWL_DELAY=100 \ -e KRAWL_DASHBOARD_SECRET_PATH="/my-secret-dashboard" \ + -e KRAWL_DASHBOARD_PASSWORD="my-secret-password" \ -v krawl-data:/app/data \ --name krawl \ ghcr.io/blessedrebus/krawl:latest @@ -138,6 +139,7 @@ services: environment: - CONFIG_LOCATION=config.yaml - TZ=Europe/Rome + # - KRAWL_DASHBOARD_PASSWORD=my-secret-password volumes: - ./config.yaml:/app/config.yaml:ro # bind mount for firewall exporters @@ -197,6 +199,7 @@ You can use the [config.yaml](config.yaml) file for advanced configurations, suc | `KRAWL_CANARY_TOKEN_URL` | External canary token URL | None | | `KRAWL_CANARY_TOKEN_TRIES` | Requests before showing canary token | `10` | | `KRAWL_DASHBOARD_SECRET_PATH` | Custom dashboard path | Auto-generated | +| `KRAWL_DASHBOARD_PASSWORD` | Password for protected dashboard panels | Auto-generated | | `KRAWL_PROBABILITY_ERROR_CODES` | Error response probability (0-100%) | `0` | | `KRAWL_DATABASE_PATH` | Database file location | `data/krawl.db` | | `KRAWL_EXPORTS_PATH` | Path where firewalls rule sets are exported | `exports` | @@ -228,8 +231,9 @@ export KRAWL_LINKS_PER_PAGE_RANGE="5,25" export KRAWL_HTTP_RISKY_METHODS_THRESHOLD="0.2" export KRAWL_VIOLATED_ROBOTS_THRESHOLD="0.15" -# Set custom dashboard path +# Set custom dashboard path and password export KRAWL_DASHBOARD_SECRET_PATH="/my-secret-dashboard" +export KRAWL_DASHBOARD_PASSWORD="my-secret-password" ``` Example of a Docker run with env variables: @@ -239,6 +243,7 @@ docker run -d \ -p 5000:5000 \ -e KRAWL_PORT=5000 \ -e KRAWL_DELAY=100 \ + -e KRAWL_DASHBOARD_PASSWORD="my-secret-password" \ -e KRAWL_CANARY_TOKEN_URL="http://your-canary-token-url" \ --name krawl \ ghcr.io/blessedrebus/krawl:latest diff --git a/docker-compose.yaml b/docker-compose.yaml index 17680de..feab0f5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,6 +10,8 @@ services: - "5000:5000" environment: - CONFIG_LOCATION=config.yaml + # Uncomment to set a custom dashboard password (auto-generated if not set) + # - KRAWL_DASHBOARD_PASSWORD=your-secret-password # set this to change timezone, alternatively mount /etc/timezone or /etc/localtime based on the time system management of the host environment # - TZ=${TZ} volumes: From 28a62f9ea13c2b88214a14a2a23abc0be34d2274 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Fri, 6 Mar 2026 22:40:27 +0100 Subject: [PATCH 4/8] feat: add instructions for setting a dashboard password and create secret.yaml template --- kubernetes/README.md | 6 +++++- kubernetes/manifests/deployment.yaml | 6 ++++++ kubernetes/manifests/secret.yaml | 15 +++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 kubernetes/manifests/secret.yaml diff --git a/kubernetes/README.md b/kubernetes/README.md index d803496..8e9bd01 100644 --- a/kubernetes/README.md +++ b/kubernetes/README.md @@ -22,13 +22,17 @@ Once the EXTERNAL-IP is assigned, access your deception server at `http:// Date: Fri, 6 Mar 2026 22:41:02 +0100 Subject: [PATCH 5/8] feat: update Helm chart for FastAPI authentication with dashboard password support --- helm/Chart.yaml | 4 ++-- helm/README.md | 1 + helm/templates/deployment.yaml | 7 +++++++ helm/templates/secret.yaml | 11 +++++++++++ helm/values.yaml | 4 ++++ 5 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 helm/templates/secret.yaml diff --git a/helm/Chart.yaml b/helm/Chart.yaml index e4e1cee..99fce39 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: krawl-chart description: A Helm chart for Krawl honeypot server type: application -version: 1.1.3 -appVersion: 1.1.3 +version: 1.1.4 +appVersion: 1.1.4 keywords: - honeypot - security diff --git a/helm/README.md b/helm/README.md index 268ca00..efdfeb1 100644 --- a/helm/README.md +++ b/helm/README.md @@ -96,6 +96,7 @@ The following table lists the main configuration parameters of the Krawl chart a | Parameter | Description | Default | |-----------|-------------|---------| | `config.dashboard.secret_path` | Secret dashboard path (auto-generated if null) | `null` | +| `dashboardPassword` | Password for protected panels (injected via Secret as `KRAWL_DASHBOARD_PASSWORD` env, auto-generated if empty) | `""` | ### API Configuration diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml index 730f774..2758e6a 100644 --- a/helm/templates/deployment.yaml +++ b/helm/templates/deployment.yaml @@ -47,6 +47,13 @@ spec: - name: TZ value: {{ .Values.timezone | quote }} {{- end }} + {{- if .Values.dashboardPassword }} + - name: KRAWL_DASHBOARD_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "krawl.fullname" . }}-dashboard + key: dashboard-password + {{- end }} volumeMounts: - name: config mountPath: /app/config.yaml diff --git a/helm/templates/secret.yaml b/helm/templates/secret.yaml new file mode 100644 index 0000000..9357ea6 --- /dev/null +++ b/helm/templates/secret.yaml @@ -0,0 +1,11 @@ +{{- if .Values.dashboardPassword }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "krawl.fullname" . }}-dashboard + labels: + {{- include "krawl.labels" . | nindent 4 }} +type: Opaque +stringData: + dashboard-password: {{ .Values.dashboardPassword | quote }} +{{- end }} diff --git a/helm/values.yaml b/helm/values.yaml index 3bdebd9..0c2e529 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -60,6 +60,10 @@ tolerations: [] affinity: {} +# Dashboard password for protected panels +# If empty, a random password will be auto-generated and printed in the logs +dashboardPassword: "" + # Application configuration (config.yaml structure) config: server: From 4fd5832fdc9f9151025eb11104d7c28cbc0b8d8c Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sat, 7 Mar 2026 17:06:29 +0100 Subject: [PATCH 6/8] feat: implement authentication modal and update dashboard password handling --- src/routes/api.py | 8 +- src/templates/jinja2/dashboard/index.html | 3 + .../jinja2/dashboard/partials/auth_modal.html | 39 +++++ src/templates/static/css/dashboard.css | 133 ++++++++++++++++++ src/templates/static/js/dashboard.js | 39 ++++- 5 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 src/templates/jinja2/dashboard/partials/auth_modal.html diff --git a/src/routes/api.py b/src/routes/api.py index 9ef8f0f..e830423 100644 --- a/src/routes/api.py +++ b/src/routes/api.py @@ -6,9 +6,10 @@ Migrated from handler.py dashboard API endpoints. All endpoints are prefixed with the secret dashboard path. """ +import hashlib +import hmac import os import secrets -import hmac from fastapi import APIRouter, Request, Response, Query, Cookie from fastapi.responses import JSONResponse, PlainTextResponse @@ -33,7 +34,7 @@ def _no_cache_headers() -> dict: class AuthRequest(BaseModel): - password: str + fingerprint: str def verify_auth(request: Request) -> bool: @@ -45,7 +46,8 @@ def verify_auth(request: Request) -> bool: @router.post("/api/auth") async def authenticate(request: Request, body: AuthRequest): config = request.app.state.config - if hmac.compare_digest(body.password, config.dashboard_password): + expected = hashlib.sha256(config.dashboard_password.encode()).hexdigest() + if hmac.compare_digest(body.fingerprint, expected): token = secrets.token_hex(32) _auth_tokens.add(token) response = JSONResponse(content={"authenticated": True}) diff --git a/src/templates/jinja2/dashboard/index.html b/src/templates/jinja2/dashboard/index.html index 8fba3e7..9a551dc 100644 --- a/src/templates/jinja2/dashboard/index.html +++ b/src/templates/jinja2/dashboard/index.html @@ -205,5 +205,8 @@ {# Raw request modal - Alpine.js #} {% include "dashboard/partials/raw_request_modal.html" %} + {# Auth modal - Alpine.js #} + {% include "dashboard/partials/auth_modal.html" %} + {% endblock %} diff --git a/src/templates/jinja2/dashboard/partials/auth_modal.html b/src/templates/jinja2/dashboard/partials/auth_modal.html new file mode 100644 index 0000000..af7ed8c --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/auth_modal.html @@ -0,0 +1,39 @@ +{# Authentication modal - Alpine.js controlled #} +
+
+
+
+ + + +

Authentication Required

+
+ × +
+
+

Enter the dashboard password to access protected panels.

+
+ +

+
+ +
+
+
diff --git a/src/templates/static/css/dashboard.css b/src/templates/static/css/dashboard.css index 98d1472..3b3c53b 100644 --- a/src/templates/static/css/dashboard.css +++ b/src/templates/static/css/dashboard.css @@ -1168,6 +1168,139 @@ tbody { opacity: 1; } +/* Auth Modal */ +.auth-modal { + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); +} +.auth-modal-content { + background-color: #161b22; + border: 1px solid #30363d; + border-radius: 12px; + width: 400px; + max-width: 90vw; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6); + animation: authModalIn 0.2s ease-out; +} +@keyframes authModalIn { + from { opacity: 0; transform: scale(0.95) translateY(-10px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} +.auth-modal-header { + padding: 20px 24px 16px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #30363d; +} +.auth-modal-title { + display: flex; + align-items: center; + gap: 10px; + color: #58a6ff; +} +.auth-modal-title h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: #e6edf3; +} +.auth-modal-close { + color: #8b949e; + font-size: 24px; + font-weight: bold; + cursor: pointer; + line-height: 20px; + transition: color 0.2s; +} +.auth-modal-close:hover { + color: #c9d1d9; +} +.auth-modal-body { + padding: 24px; +} +.auth-modal-description { + margin: 0 0 20px; + color: #8b949e; + font-size: 14px; + line-height: 1.5; +} +.auth-modal-input-group { + margin-bottom: 20px; +} +.auth-modal-input { + width: 100%; + padding: 10px 14px; + background: #0d1117; + border: 1px solid #30363d; + border-radius: 6px; + color: #e6edf3; + font-size: 14px; + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; + box-sizing: border-box; +} +.auth-modal-input:focus { + border-color: #58a6ff; + box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15); +} +.auth-modal-input-error { + border-color: #f85149; +} +.auth-modal-input-error:focus { + border-color: #f85149; + box-shadow: 0 0 0 3px rgba(248, 81, 73, 0.15); +} +.auth-modal-error { + margin: 8px 0 0; + color: #f85149; + font-size: 13px; +} +.auth-modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; +} +.auth-modal-btn { + padding: 8px 20px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border: 1px solid transparent; + transition: all 0.2s; +} +.auth-modal-btn-cancel { + background: transparent; + border-color: #30363d; + color: #8b949e; +} +.auth-modal-btn-cancel:hover { + background: #21262d; + color: #c9d1d9; +} +.auth-modal-btn-submit { + background: #238636; + color: #fff; + border-color: rgba(240, 246, 252, 0.1); +} +.auth-modal-btn-submit:hover { + background: #2ea043; +} +.auth-modal-btn-submit:disabled { + opacity: 0.6; + cursor: not-allowed; +} + /* Attack Types Cell Styling */ .attack-types-cell { max-width: 280px; diff --git a/src/templates/static/js/dashboard.js b/src/templates/static/js/dashboard.js index c1b12d5..719d9dd 100644 --- a/src/templates/static/js/dashboard.js +++ b/src/templates/static/js/dashboard.js @@ -22,6 +22,7 @@ document.addEventListener('alpine:init', () => { // Auth state (UI only — actual security enforced server-side via cookie) authenticated: false, + authModal: { show: false, password: '', error: '', loading: false }, async init() { // Check if already authenticated (cookie-based) @@ -97,24 +98,50 @@ document.addEventListener('alpine:init', () => { if (this.tab === 'admin') this.switchToOverview(); }, - async promptAuth() { - const password = prompt('Enter dashboard password:'); - if (!password) return; + promptAuth() { + this.authModal = { show: true, password: '', error: '', loading: false }; + this.$nextTick(() => { + if (this.$refs.authPasswordInput) this.$refs.authPasswordInput.focus(); + }); + }, + + closeAuthModal() { + this.authModal.show = false; + this.authModal.password = ''; + this.authModal.error = ''; + this.authModal.loading = false; + }, + + async submitAuth() { + const password = this.authModal.password; + if (!password) { + this.authModal.error = 'Please enter a password'; + return; + } + this.authModal.error = ''; + this.authModal.loading = true; try { + const msgBuf = new TextEncoder().encode(password); + const hashBuf = await crypto.subtle.digest('SHA-256', msgBuf); + const fingerprint = Array.from(new Uint8Array(hashBuf)) + .map(b => b.toString(16).padStart(2, '0')).join(''); const resp = await fetch(`${this.dashboardPath}/api/auth`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', - body: JSON.stringify({ password }), + body: JSON.stringify({ fingerprint }), }); if (resp.ok) { this.authenticated = true; + this.closeAuthModal(); this.switchToAdmin(); } else { - alert('Invalid password'); + this.authModal.error = 'Invalid password'; + this.authModal.loading = false; } } catch { - alert('Authentication failed'); + this.authModal.error = 'Authentication failed'; + this.authModal.loading = false; } }, From e1ec7ede4513c88bfbe93daec490d449a02ac26b Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sat, 7 Mar 2026 17:09:22 +0100 Subject: [PATCH 7/8] feat: implement brute force protection and error handling for authentication --- src/routes/api.py | 57 +++++++++++++++++++++++++++- src/templates/static/js/dashboard.js | 16 +++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/routes/api.py b/src/routes/api.py index e830423..9830af9 100644 --- a/src/routes/api.py +++ b/src/routes/api.py @@ -10,6 +10,7 @@ import hashlib import hmac import os import secrets +import time from fastapi import APIRouter, Request, Response, Query, Cookie from fastapi.responses import JSONResponse, PlainTextResponse @@ -21,6 +22,12 @@ from logger import get_app_logger # Server-side session token store (valid tokens for authenticated sessions) _auth_tokens: set = set() +# Bruteforce protection: tracks failed attempts per IP +# { ip: { "attempts": int, "locked_until": float } } +_auth_attempts: dict = {} +_AUTH_MAX_ATTEMPTS = 5 +_AUTH_BASE_LOCKOUT = 30 # seconds, doubles on each lockout + router = APIRouter() @@ -45,9 +52,27 @@ def verify_auth(request: Request) -> bool: @router.post("/api/auth") async def authenticate(request: Request, body: AuthRequest): + ip = request.client.host + + # Check if IP is currently locked out + record = _auth_attempts.get(ip) + if record and record["locked_until"] > time.time(): + remaining = int(record["locked_until"] - time.time()) + return JSONResponse( + content={ + "authenticated": False, + "error": f"Too many attempts. Try again in {remaining}s", + "locked": True, + "retry_after": remaining, + }, + status_code=429, + ) + config = request.app.state.config expected = hashlib.sha256(config.dashboard_password.encode()).hexdigest() if hmac.compare_digest(body.fingerprint, expected): + # Success — clear failed attempts + _auth_attempts.pop(ip, None) token = secrets.token_hex(32) _auth_tokens.add(token) response = JSONResponse(content={"authenticated": True}) @@ -58,8 +83,38 @@ async def authenticate(request: Request, body: AuthRequest): samesite="strict", ) return response + + # Failed attempt — track and possibly lock out + if not record: + record = {"attempts": 0, "locked_until": 0, "lockouts": 0} + _auth_attempts[ip] = record + record["attempts"] += 1 + + if record["attempts"] >= _AUTH_MAX_ATTEMPTS: + lockout = _AUTH_BASE_LOCKOUT * (2 ** record["lockouts"]) + record["locked_until"] = time.time() + lockout + record["lockouts"] += 1 + record["attempts"] = 0 + get_app_logger().warning( + f"Auth bruteforce: IP {ip} locked out for {lockout}s " + f"(lockout #{record['lockouts']})" + ) + return JSONResponse( + content={ + "authenticated": False, + "error": f"Too many attempts. Locked for {lockout}s", + "locked": True, + "retry_after": lockout, + }, + status_code=429, + ) + + remaining_attempts = _AUTH_MAX_ATTEMPTS - record["attempts"] return JSONResponse( - content={"authenticated": False, "error": "Invalid password"}, + content={ + "authenticated": False, + "error": f"Invalid password. {remaining_attempts} attempt{'s' if remaining_attempts != 1 else ''} remaining", + }, status_code=401, ) diff --git a/src/templates/static/js/dashboard.js b/src/templates/static/js/dashboard.js index 719d9dd..66e3eac 100644 --- a/src/templates/static/js/dashboard.js +++ b/src/templates/static/js/dashboard.js @@ -136,8 +136,22 @@ document.addEventListener('alpine:init', () => { this.closeAuthModal(); this.switchToAdmin(); } else { - this.authModal.error = 'Invalid password'; + const data = await resp.json().catch(() => ({})); + this.authModal.error = data.error || 'Invalid password'; + this.authModal.password = ''; this.authModal.loading = false; + if (data.locked && data.retry_after) { + let remaining = data.retry_after; + const interval = setInterval(() => { + remaining--; + if (remaining <= 0) { + clearInterval(interval); + this.authModal.error = ''; + } else { + this.authModal.error = `Too many attempts. Try again in ${remaining}s`; + } + }, 1000); + } } } catch { this.authModal.error = 'Authentication failed'; From f391da202549b4b698c5ab30ba055379b71f246b Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Sat, 7 Mar 2026 17:14:07 +0100 Subject: [PATCH 8/8] code linted --- src/app.py | 4 +++- src/routes/htmx.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/app.py b/src/app.py index 87904b8..7d313bb 100644 --- a/src/app.py +++ b/src/app.py @@ -74,7 +74,9 @@ async def lifespan(app: FastAPI): password_line = "" if config.dashboard_password_generated: - password_line = f"\n\nDASHBOARD PASSWORD (auto-generated)\n{config.dashboard_password}" + password_line = ( + f"\n\nDASHBOARD PASSWORD (auto-generated)\n{config.dashboard_password}" + ) banner = f""" diff --git a/src/routes/htmx.py b/src/routes/htmx.py index a793c87..366c4a0 100644 --- a/src/routes/htmx.py +++ b/src/routes/htmx.py @@ -421,9 +421,9 @@ async def htmx_admin(request: Request): return HTMLResponse( '
' '

Nice try bozo

' - '
' + "
" 'Diddy' - '
', + "", status_code=200, ) templates = get_templates()