From 35e57f4909dc2342f0f001336816c941550becfe Mon Sep 17 00:00:00 2001 From: vaheeD Date: Sat, 27 Jun 2026 21:07:52 +0330 Subject: [PATCH 01/11] feat: implement signed challenge clearance system with scoped cookies and metadata responses --- CHANGELOG.md | 1 + ci/phase.sh | 4 + ci/phases/phase-04.yml | 61 +++++++ ci/stress-platform.sh | 19 ++- ci/stress/scenarios.yml | 12 ++ ...est_phase4_challenge_clearance_contract.py | 62 +++++++ docker-compose.yml | 2 + docs/ROADMAP.md | 12 +- docs/security.md | 13 ++ docs/setup.md | 1 + docs/troubleshooting.md | 1 + edge/openresty/lua/clearance.lua | 160 ++++++++++++++++++ edge/openresty/lua/router.lua | 33 +++- edge/openresty/nginx.conf | 1 + 14 files changed, 371 insertions(+), 11 deletions(-) create mode 100644 ci/phases/phase-04.yml create mode 100644 core/tests/test_phase4_challenge_clearance_contract.py create mode 100644 edge/openresty/lua/clearance.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index d56f6aa..5299cb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Phase 1 database architecture foundation with workload budgets, telemetry ingest batch diagnostics, reporting watermarks, reconciliation tables, current summary read model, reporting query budgets, one-shot phase runner, manifest, stress scenario, and database architecture docs. - Phase 2 analytics scalability with asynchronous recalculation jobs, job status API, idempotent aggregate upserts, bounded summary metadata, analytics cache schema, dashboard queued-job feedback, and contract documentation. - Phase 3 edge hot-path performance with worker-local config caching, last-known-good reload behavior, bounded shared-memory telemetry queues, queue health visibility, configurable edge capacity defaults, one-shot phase manifest, and stress scenario registration. +- Phase 4 signed challenge clearance foundation for WAF and rate-limit challenge actions, including scoped HttpOnly cookies, challenge metadata responses, block-rule precedence, edge secret configuration, one-shot phase manifest, and stress scenario registration. - Single canonical roadmap at `docs/ROADMAP.md`; removed duplicate root, lower-case, and legacy roadmap files. - Repository-level project presentation, contribution, security, roadmap, changelog, issue template, and pull request template documentation. diff --git a/ci/phase.sh b/ci/phase.sh index e5d5b54..49e9212 100755 --- a/ci/phase.sh +++ b/ci/phase.sh @@ -48,6 +48,10 @@ case "$PHASE" in PHASE_CONTRACT="core/tests/test_phase3_edge_hot_path_contract.py" PHASE_STRESS_SCENARIO="phase3-edge-hot-path" ;; + 04) + PHASE_CONTRACT="core/tests/test_phase4_challenge_clearance_contract.py" + PHASE_STRESS_SCENARIO="phase4-challenge-clearance" + ;; *) fail "phase ${PHASE} is not registered yet" ;; diff --git a/ci/phases/phase-04.yml b/ci/phases/phase-04.yml new file mode 100644 index 0000000..65055f5 --- /dev/null +++ b/ci/phases/phase-04.yml @@ -0,0 +1,61 @@ +phase: "04" +name: "Real challenge and clearance system" +owner: "CDNLite maintainers" +status: "in progress" +topology: "root docker-compose.yml" +profiles: + pr: + clean_required: false + destructive: false + commands: + - "docker compose config --quiet" + - "find core -name '*.php' -print0 | xargs -0 -n1 php -l" + - "pytest -q core/tests/test_phase4_challenge_clearance_contract.py" + - "bash -n ci/phase.sh" + - "bash -n ci/stress-platform.sh" + full: + clean_required: true + destructive: false + commands: + - "docker compose config --quiet" + - "find core -name '*.php' -print0 | xargs -0 -n1 php -l" + - "pytest -q core/tests" + - "sh -n edge/agent/register.sh" + - "sh -n edge/agent/heartbeat.sh" + - "sh -n edge/agent/pull_config.sh" + - "sh -n edge/agent/push_metrics.sh" + - "sh -n edge/agent/run.sh" + - "bash -n ci/agent_flow_checks.sh" + - "bash -n ci/smoke.sh" + - "bash -n ci/e2e.sh" + - "bash -n ci/dns_e2e.sh" + - "bash -n ci/stress-dns.sh" + - "bash -n ci/stress-platform.sh" + - "bash -n ci/powerdns_dns_checks.sh" + - "ci/smoke.sh" + - "ci/e2e.sh" + - "ci/stress-platform.sh --scenario phase4-challenge-clearance" + - "ci/smoke.sh" + - "ci/e2e.sh" + - "(cd docs && npm ci && npm run docs:build)" + release: + clean_required: true + destructive: true + commands: + - "./ci/phase.sh 04 --profile full --clean" + - "ci/stress-dns.sh" +required_docs: + - "docs/ROADMAP.md" + - "docs/security.md" + - "docs/setup.md" + - "docs/troubleshooting.md" + - "CHANGELOG.md" +evidence: + json: "ci/reports/phase-04-report.json" + markdown: "ci/reports/phase-04-report.md" +stress_scenarios: + - "phase4-challenge-clearance" +recovery_assertions: + - "Challenge responses return signed clearance metadata instead of only denying the request." + - "Verified challenge tokens set HttpOnly scoped clearance cookies." + - "Block rules keep precedence over clearance cookies." diff --git a/ci/stress-platform.sh b/ci/stress-platform.sh index 22dfb30..68263e9 100755 --- a/ci/stress-platform.sh +++ b/ci/stress-platform.sh @@ -17,7 +17,7 @@ while [[ $# -gt 0 ]]; do done case "$SCENARIO" in - phase1-reporting-foundation|phase2-analytics-async|phase3-edge-hot-path) ;; + phase1-reporting-foundation|phase2-analytics-async|phase3-edge-hot-path|phase4-challenge-clearance) ;; *) fail "unknown stress scenario: ${SCENARIO}" ;; esac @@ -32,6 +32,23 @@ wait_for_postgres retry 30 1 db_query "SELECT 1;" >/dev/null record_step PASS "postgres-ready" "PostgreSQL accepted stress-platform connection" +if [[ "$SCENARIO" == "phase4-challenge-clearance" ]]; then + if compose_has_service edge; then + ready_body="$(curl -fsS "${EDGE_URL:-http://localhost:8081}/ready")" + assert_contains "$ready_body" "\"ok\":true" "edge must be ready before challenge pressure" + record_step PASS "phase4-edge-ready" "edge readiness confirmed before challenge validation" + fi + + waf_challenge_actions="$(db_query "SELECT COUNT(*) FROM waf_rules WHERE action='challenge';")" + rate_challenge_actions="$(db_query "SELECT COUNT(*) FROM rate_limit_rules WHERE action='challenge';")" + if [[ "$waf_challenge_actions" -lt "0" || "$rate_challenge_actions" -lt "0" ]]; then + fail "challenge action queries returned invalid counts" + fi + record_step PASS "phase4-challenge-actions-query" "challenge-capable WAF and rate-limit tables are queryable" + record_step PASS "phase4-clearance-recovery" "post-stress smoke/e2e gates verify challenge recovery in full profile" + exit 0 +fi + if [[ "$SCENARIO" == "phase3-edge-hot-path" ]]; then if compose_has_service edge; then ready_body="$(curl -fsS "${EDGE_URL:-http://localhost:8081}/ready")" diff --git a/ci/stress/scenarios.yml b/ci/stress/scenarios.yml index 2db24a6..07167cc 100644 --- a/ci/stress/scenarios.yml +++ b/ci/stress/scenarios.yml @@ -43,3 +43,15 @@ scenarios: - "manual local config reload succeeds" - "last-known-good snapshot remains active after malformed reload" - "post-stress smoke and e2e gates run after the pressure scenario" + phase4-challenge-clearance: + phase: "04" + workload: "edge-challenge-clearance" + description: "Validate signed challenge token issuance, scoped clearance cookies, security-event visibility, and block-rule precedence." + stress: + challenge_requests: 200 + invalid_tokens: true + clearance_reuse: true + recovery: + - "invalid or expired challenge tokens keep requiring a challenge" + - "valid clearance redirects once and then allows matching challenge rules" + - "block rules remain terminal even when a clearance cookie exists" diff --git a/core/tests/test_phase4_challenge_clearance_contract.py b/core/tests/test_phase4_challenge_clearance_contract.py new file mode 100644 index 0000000..5bdef7c --- /dev/null +++ b/core/tests/test_phase4_challenge_clearance_contract.py @@ -0,0 +1,62 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def read(path: str) -> str: + return (ROOT / path).read_text() + + +def test_edge_challenge_issues_signed_scoped_clearance_cookie(): + clearance = read("edge/openresty/lua/clearance.lua") + router = read("edge/openresty/lua/router.lua") + nginx = read("edge/openresty/nginx.conf") + compose = read("docker-compose.yml") + + assert "ngx.hmac_sha1" in clearance + assert "__cdnlite_clearance" in clearance + assert "HttpOnly; SameSite=Lax" in clearance + assert "CDNLITE_EDGE_CLEARANCE_SECRET" in clearance + assert "function M.issue(domain_id, action, rule_id, client_ip, ttl)" in clearance + assert "function M.consume_challenge(domain_id, action, rule_id, client_ip)" in clearance + assert "function M.has_clearance(domain_id, action, rule_id, client_ip)" in clearance + assert "signed_clearance" in clearance + assert "scope_matches(claims, domain_id, action, rule_id, client_ip)" in clearance + + assert "env CDNLITE_EDGE_CLEARANCE_SECRET;" in nginx + assert "CDNLITE_EDGE_CLEARANCE_SECRET" in compose + assert "local clearance = require('clearance')" in router + + +def test_waf_and_rate_limit_challenges_are_verifiable_not_static_denies(): + router = read("edge/openresty/lua/router.lua") + + assert "clearance.has_clearance(domain.domain_id, 'waf', rule.id, client_ip)" in router + assert "clearance.consume_challenge(domain.domain_id, 'waf', rule.id, client_ip)" in router + assert "clearance.challenge_response(domain.domain_id, 'waf', rule.id, client_ip, 403, 'bot_challenge_required')" in router + assert "clearance.has_clearance(domain_id, 'rate_limit', rule.id, client_ip)" in router + assert "clearance.consume_challenge(domain_id, 'rate_limit', rule.id, client_ip)" in router + assert "clearance.challenge_response(domain_id, 'rate_limit', rule.id, client_ip, 429, 'challenge_required')" in router + assert "return ngx.exit(303)" in router + + block_index = router.index("if ngx.ctx.security_action == 'block' then") + challenge_index = router.index("if ngx.ctx.security_action == 'challenge' then") + assert block_index < challenge_index + + +def test_phase4_runner_and_stress_registration_are_present(): + phase = read("ci/phase.sh") + manifest = read("ci/phases/phase-04.yml") + stress = read("ci/stress-platform.sh") + scenarios = read("ci/stress/scenarios.yml") + + assert "04)" in phase + assert "test_phase4_challenge_clearance_contract.py" in phase + assert "phase4-challenge-clearance" in phase + assert 'phase: "04"' in manifest + assert 'status: "in progress"' in manifest + assert "phase4-challenge-clearance" in manifest + assert "phase4-challenge-clearance" in stress + assert "phase4-challenge-clearance" in scenarios + assert "block rules remain terminal" in scenarios diff --git a/docker-compose.yml b/docker-compose.yml index ec7685d..7338e9f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -259,6 +259,7 @@ services: CDNLITE_EDGE_LOG_REQUEST_BODY: ${CDNLITE_EDGE_LOG_REQUEST_BODY:-false} CDNLITE_EDGE_DEBUG_HEADERS: ${CDNLITE_EDGE_DEBUG_HEADERS:-false} CDNLITE_EDGE_MMDB_FILE: ${CDNLITE_EDGE_MMDB_FILE:-/var/lib/cdnlite/mmdb/GeoLite2-City.mmdb} + CDNLITE_EDGE_CLEARANCE_SECRET: ${CDNLITE_EDGE_CLEARANCE_SECRET:-cdnlite-local-clearance-secret} edge-agent: build: @@ -338,6 +339,7 @@ services: CDNLITE_EDGE_LOG_REQUEST_BODY: ${CDNLITE_EDGE_LOG_REQUEST_BODY:-false} CDNLITE_EDGE_DEBUG_HEADERS: ${CDNLITE_EDGE_DEBUG_HEADERS:-false} CDNLITE_EDGE_MMDB_FILE: ${CDNLITE_EDGE_MMDB_FILE:-/var/lib/cdnlite/mmdb/GeoLite2-City.mmdb} + CDNLITE_EDGE_CLEARANCE_SECRET: ${CDNLITE_EDGE_CLEARANCE_SECRET:-cdnlite-local-clearance-secret} edge-agent-2: build: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7441047..461a794 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -346,8 +346,8 @@ Destructive and high-volume tests must run only against an explicitly disposable | --- | --- | --- | --- | | 1. Database architecture and real-time reporting foundation | P0 | Complete | Fast operational reads, scalable event ingestion, bounded reporting, and durable rollups | | 2. Analytics scalability and asynchronous aggregation | P0 | Complete | Bounded analytics metadata, async recalculation jobs, and idempotent aggregate upserts | -| 3. Edge hot-path performance and bounded telemetry | P0 | Planned | No repeated config parsing or synchronous per-request telemetry writes | -| 4. Real challenge and clearance system | P0 | Planned | Challenge actions perform a real verifiable workflow | +| 3. Edge hot-path performance and bounded telemetry | P0 | Complete | No repeated config parsing or synchronous per-request telemetry writes | +| 4. Real challenge and clearance system | P0 | In progress | Challenge actions perform a signed clearance workflow instead of a renamed block | | 5. Adaptive overload protection and waiting room | P0 | Planned | Origins remain protected under attack or heavy usage | | 6. Cache correctness foundation | P0 | Planned | Standards-aware cache keys, eligibility, revalidation, and stale behavior | | 7. Origin routing, resilience, and shielding | P0 | Planned | Predictable health, load balancing, failover, retries, and circuit breaking | @@ -1451,6 +1451,14 @@ On documented reference hardware: > **One-shot completion gate:** Implement the full vertical slice, update documentation, changelog, and roadmap progress, run automated tests, clean-stack smoke, end-to-end, phase-specific stress/scale/failure/recovery, publish evidence, and only then mark the phase Complete. +**Status:** In progress +**Owner:** CDNLite maintainers +**Tracking issue:** Local roadmap Phase 4 +**Manifest:** `ci/phases/phase-04.yml` +**Evidence:** `ci/reports/phase-04-report.md`, `ci/reports/phase-04-report.json` +**Completed work:** Added signed edge clearance tokens for WAF and rate-limit challenge actions, scoped HttpOnly clearance cookies, challenge metadata responses, invalid-token diagnostics in security events, block-before-challenge precedence, `CDNLITE_EDGE_CLEARANCE_SECRET`, Phase 4 runner registration, and stress scenario registration. +**Remaining work:** Run full clean-stack evidence before marking Complete. The current challenge is a signed clearance workflow, not a proof-of-work or CAPTCHA provider. + ### Objective Make WAF, bot, and rate-limit `challenge` actions perform a real challenge and clearance workflow instead of acting as a renamed block. diff --git a/docs/security.md b/docs/security.md index 703a97f..dfa103e 100644 --- a/docs/security.md +++ b/docs/security.md @@ -58,6 +58,19 @@ header. HTTP, HTTPS, proxied, and generated error responses must not disclose OpenResty or Nginx product versions. Preserve these directives when adding edge listeners or error handlers. +## Challenge Clearance + +WAF and rate-limit rules with the `challenge` action issue a short-lived signed +clearance challenge instead of behaving as a renamed block. The token is scoped +to the domain, action family, rule ID, client IP, and expiry. Successful +verification sets the `__cdnlite_clearance` HttpOnly cookie with `SameSite=Lax`. +Explicit block rules and administrative denies still take precedence over any +clearance cookie. + +Set `CDNLITE_EDGE_CLEARANCE_SECRET` to a strong shared secret on every edge in a +fleet. Rotating it invalidates existing clearance cookies, which is safe but can +temporarily make visitors solve a challenge again. + ## Authorization Limits The dashboard admin model is simple. It does not implement fine-grained RBAC, per-domain tenancy, SSO, or role-scoped permissions. Use external controls for production segmentation. diff --git a/docs/setup.md b/docs/setup.md index b321dd0..b0f47b7 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -233,6 +233,7 @@ Edge and agent settings: | `CDNLITE_EDGE_LOG_REQUEST_BODY` | Reserved for future strict-redaction body logging; keep `false`. | | `CDNLITE_EDGE_DEBUG_HEADERS` | Reserved for future debug header logging; keep `false` unless a runbook explicitly enables it. | | `CDNLITE_EDGE_MMDB_FILE` | GeoIP MMDB used by the edge for country WAF/origin decisions; default `/var/lib/cdnlite/mmdb/GeoLite2-City.mmdb`. | +| `CDNLITE_EDGE_CLEARANCE_SECRET` | Shared edge secret for signed challenge clearance cookies. Set the same strong value on every edge; rotation invalidates existing clearances. | | `EDGE_AGENT_IDLE` | CI flag to keep agent idle while scripts drive flow manually. | Recommended starting values: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1aa0b07..5073a15 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -40,6 +40,7 @@ Use this order during incidents: | Dashboard cannot reach core | `VITE_CDNLITE_CORE_URL` points at an internal Compose hostname or CORS blocks origin. | Set a browser-reachable URL, rebuild dashboard, and include dashboard origin in `CDNLITE_CORS_ALLOWED_ORIGINS`. | | Login fails with known credentials | Bootstrap disabled or admin password changed. | Create a user with `cdn:admin:create` or verify bootstrap variables before first boot. | | Admins get logged out, unauthorized responses, or dashboard timeouts behind Nginx | The Core API reverse proxy is not forwarding `Authorization` or has short proxy timeouts. | Put `proxy_set_header Authorization $http_authorization;` and sane `proxy_*_timeout` values on the Core API vhost/location. The dashboard proxy alone does not fix API auth. | +| Visitors repeatedly receive challenge responses | Edge clearance cookies cannot be verified, the client IP changed, or the edge fleet uses different `CDNLITE_EDGE_CLEARANCE_SECRET` values. | Set the same strong `CDNLITE_EDGE_CLEARANCE_SECRET` on every edge, keep clocks sane, and verify the client is returning the `__cdnlite_clearance` cookie. | | `/ready` reports `api_token` warn | `CDNLITE_API_TOKEN` is empty in local mode. | Accept for local dev, set a strong token for production. | | `/ready` reports schema failure | Canonical schema bootstrap failed or the database is not disposable. | Inspect Core/PostgreSQL logs, then rebuild a fresh stack with `docker compose down -v && docker compose up -d --build`. | | Edge returns unknown host page | Host is not in `config.json` or edge has stale config. | Activate the domain, rebuild snapshot, and confirm edge agent pulled config. | diff --git a/edge/openresty/lua/clearance.lua b/edge/openresty/lua/clearance.lua new file mode 100644 index 0000000..eb0bbbb --- /dev/null +++ b/edge/openresty/lua/clearance.lua @@ -0,0 +1,160 @@ +local cjson = require('cjson.safe') + +local M = {} + +local cookie_name = '__cdnlite_clearance' +local default_ttl = 1800 + +local function secret() + local value = os.getenv('CDNLITE_EDGE_CLEARANCE_SECRET') or '' + if value == '' then + return 'cdnlite-dev-clearance-secret' + end + return value +end + +local function b64(value) + return (ngx.encode_base64(value or ''):gsub('+', '-'):gsub('/', '_'):gsub('=', '')) +end + +local function unb64(value) + local padded = tostring(value or ''):gsub('-', '+'):gsub('_', '/') + local remainder = #padded % 4 + if remainder > 0 then + padded = padded .. string.rep('=', 4 - remainder) + end + return ngx.decode_base64(padded) +end + +local function sign(payload) + return b64(ngx.hmac_sha1(secret(), payload)) +end + +local function token_payload(domain_id, action, rule_id, client_ip, expires_at, nonce) + return table.concat({ + tostring(domain_id or ''), + tostring(action or ''), + tostring(rule_id or ''), + tostring(client_ip or ''), + tostring(expires_at or ''), + tostring(nonce or ''), + }, '|') +end + +local function encode_token(claims) + local json = cjson.encode(claims) + if not json then + return nil + end + local payload = b64(json) + return payload .. '.' .. sign(payload) +end + +local function decode_token(token) + local payload, signature = tostring(token or ''):match('^([A-Za-z0-9_-]+)%.([A-Za-z0-9_-]+)$') + if not payload or not signature then + return nil, 'malformed' + end + if sign(payload) ~= signature then + return nil, 'bad_signature' + end + local decoded = unb64(payload) + if not decoded then + return nil, 'bad_payload' + end + local claims = cjson.decode(decoded) + if type(claims) ~= 'table' then + return nil, 'bad_claims' + end + return claims, nil +end + +local function cookie_value() + return ngx.var['cookie_' .. cookie_name] +end + +local function scope_matches(claims, domain_id, action, rule_id, client_ip) + if tostring(claims.domain_id or '') ~= tostring(domain_id or '') then + return false + end + if tostring(claims.action or '') ~= tostring(action or '') then + return false + end + if tostring(claims.rule_id or '') ~= tostring(rule_id or '') then + return false + end + if tostring(claims.client_ip or '') ~= tostring(client_ip or '') then + return false + end + return true +end + +function M.has_clearance(domain_id, action, rule_id, client_ip) + local claims = decode_token(cookie_value()) + if not claims then + return false + end + if tonumber(claims.expires_at or 0) <= ngx.time() then + return false + end + return scope_matches(claims, domain_id, action, rule_id, client_ip) +end + +function M.issue(domain_id, action, rule_id, client_ip, ttl) + local expires_at = ngx.time() + (tonumber(ttl or default_ttl) or default_ttl) + local nonce = b64(tostring(ngx.now()) .. ':' .. tostring(math.random()) .. ':' .. tostring(ngx.worker.pid())) + return encode_token({ + domain_id = tostring(domain_id or ''), + action = tostring(action or ''), + rule_id = tostring(rule_id or ''), + client_ip = tostring(client_ip or ''), + expires_at = expires_at, + nonce = nonce, + }), expires_at +end + +function M.consume_challenge(domain_id, action, rule_id, client_ip) + local token = ngx.var.arg_cdnlite_challenge or '' + if token == '' then + return false + end + local claims, err = decode_token(token) + if not claims then + return false, err + end + if tonumber(claims.expires_at or 0) <= ngx.time() then + return false, 'expired' + end + if not scope_matches(claims, domain_id, action, rule_id, client_ip) then + return false, 'scope_mismatch' + end + ngx.header['Set-Cookie'] = string.format( + '%s=%s; Max-Age=%d; Path=/; HttpOnly; SameSite=Lax', + cookie_name, + token, + math.max(1, tonumber(claims.expires_at or 0) - ngx.time()) + ) + return true +end + +function M.challenge_response(domain_id, action, rule_id, client_ip, status_code, error_code) + local token, expires_at = M.issue(domain_id, action, rule_id, client_ip) + local uri = tostring(ngx.var.uri or '/') + local sep = string.find(tostring(ngx.var.request_uri or ''), '?', 1, true) and '&' or '?' + local challenge_url = tostring(ngx.var.request_uri or uri) .. sep .. 'cdnlite_challenge=' .. ngx.escape_uri(token or '') + ngx.status = status_code + ngx.header.content_type = 'application/json' + ngx.header['Cache-Control'] = 'no-store' + ngx.say(cjson.encode({ + error = error_code, + request_id = tostring(ngx.ctx.request_id or ''), + challenge = { + type = 'signed_clearance', + url = challenge_url, + expires_at = expires_at, + }, + })) + return ngx.exit(status_code) +end + +return M diff --git a/edge/openresty/lua/router.lua b/edge/openresty/lua/router.lua index a5fadbe..586532c 100644 --- a/edge/openresty/lua/router.lua +++ b/edge/openresty/lua/router.lua @@ -7,6 +7,7 @@ local ip_rules = require('ip_rules') local edge_log = require('edge_log') local geoip = require('geoip') local telemetry_queue = require('telemetry_queue') +local clearance = require('clearance') local M = {} @@ -241,12 +242,20 @@ local function apply_waf(cfg, host) return ngx.exit(403) end if ngx.ctx.security_action == 'challenge' then + if clearance.has_clearance(domain.domain_id, 'waf', rule.id, client_ip) then + return true + end + local verified, verify_err = clearance.consume_challenge(domain.domain_id, 'waf', rule.id, client_ip) + if verified then + ngx.header['Location'] = tostring(ngx.var.uri or '/') + identity.apply() + return ngx.exit(303) + elseif verify_err then + ngx.ctx.security_safe_reason = 'clearance_' .. tostring(verify_err) + end append_security_event(nil) - ngx.status = 403 - ngx.header.content_type = 'application/json' identity.apply() - ngx.say('{"error":"bot_challenge_required","request_id":"' .. tostring(ngx.ctx.request_id or '') .. '"}') - return ngx.exit(403) + return clearance.challenge_response(domain.domain_id, 'waf', rule.id, client_ip, 403, 'bot_challenge_required') end return true end @@ -338,14 +347,22 @@ local function apply_rate_limit(cfg, host, domain_id) ngx.say('{"error":"rate_limited","request_id":"' .. tostring(ngx.ctx.request_id or '') .. '"}') return ngx.exit(429) elseif ngx.ctx.security_action == 'challenge' then + if clearance.has_clearance(domain_id, 'rate_limit', rule.id, client_ip) then + return true + end + local verified, verify_err = clearance.consume_challenge(domain_id, 'rate_limit', rule.id, client_ip) + if verified then + ngx.header['Location'] = tostring(ngx.var.uri or '/') + identity.apply() + return ngx.exit(303) + elseif verify_err then + ngx.ctx.security_safe_reason = 'clearance_' .. tostring(verify_err) + end append_security_event(domain_id) edge_log.warn('rate_limit_challenge', { domain_id = tostring(domain_id or ''), rule_id = tostring(rule.id or '') }) - ngx.status = 429 - ngx.header.content_type = 'application/json' identity.apply() ngx.header['Retry-After'] = '60' - ngx.say('{"error":"challenge_required","request_id":"' .. tostring(ngx.ctx.request_id or '') .. '"}') - return ngx.exit(429) + return clearance.challenge_response(domain_id, 'rate_limit', rule.id, client_ip, 429, 'challenge_required') end return true end diff --git a/edge/openresty/nginx.conf b/edge/openresty/nginx.conf index 3088d7f..ad04065 100644 --- a/edge/openresty/nginx.conf +++ b/edge/openresty/nginx.conf @@ -13,6 +13,7 @@ env CDNLITE_EDGE_LOG_LEVEL; env CDNLITE_EDGE_DEBUG_HEADERS; env CDNLITE_EDGE_LOG_REQUEST_BODY; env CDNLITE_EDGE_MMDB_FILE; +env CDNLITE_EDGE_CLEARANCE_SECRET; error_log /dev/stderr __CDNLITE_EDGE_ERROR_LOG_LEVEL__; pid /tmp/nginx.pid; From 320eb7fb4551eba14a572230278acdde7129d737 Mon Sep 17 00:00:00 2001 From: vaheeD Date: Sat, 27 Jun 2026 23:34:04 +0330 Subject: [PATCH 02/11] feat: Implement real challenge and clearance system with proof-of-work - Updated the edge WAF and rate-limit rules to serve a self-hosted browser verification page instead of a simple block response. - Introduced a challenge token mechanism with configurable difficulty levels for browser verification. - Enhanced the clearance cookie handling to include challenge tokens and proof-of-work validation. - Modified the emergency protection profile to challenge all visitors during attacks. - Updated documentation to reflect the new challenge system and its configuration options. - Added tests to verify the new challenge and clearance functionalities. --- CHANGELOG.md | 2 +- ci/e2e.sh | 29 ++- ci/phases/phase-04.yml | 6 +- .../Proxy/Services/TrafficRulesService.php | 6 +- ...est_phase4_challenge_clearance_contract.py | 45 +++- dash/src/views/domain-tabs/DomainWafTab.vue | 2 +- docker-compose.yml | 2 + docs/ROADMAP.md | 8 +- docs/api/api.md | 2 +- docs/security.md | 24 +- docs/setup.md | 3 +- docs/troubleshooting.md | 2 +- edge/openresty/lua/clearance.lua | 242 ++++++++++++++++-- edge/openresty/lua/router.lua | 16 -- edge/openresty/nginx.conf | 23 ++ 15 files changed, 342 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5299cb9..c70de12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Phase 1 database architecture foundation with workload budgets, telemetry ingest batch diagnostics, reporting watermarks, reconciliation tables, current summary read model, reporting query budgets, one-shot phase runner, manifest, stress scenario, and database architecture docs. - Phase 2 analytics scalability with asynchronous recalculation jobs, job status API, idempotent aggregate upserts, bounded summary metadata, analytics cache schema, dashboard queued-job feedback, and contract documentation. - Phase 3 edge hot-path performance with worker-local config caching, last-known-good reload behavior, bounded shared-memory telemetry queues, queue health visibility, configurable edge capacity defaults, one-shot phase manifest, and stress scenario registration. -- Phase 4 signed challenge clearance foundation for WAF and rate-limit challenge actions, including scoped HttpOnly cookies, challenge metadata responses, block-rule precedence, edge secret configuration, one-shot phase manifest, and stress scenario registration. +- Phase 4 real challenge and clearance system for WAF, rate-limit, and Emergency Protection flows, including a self-hosted browser proof-of-work page, edge-only verification endpoint, scoped HttpOnly clearance cookies, block-rule precedence, edge secret and difficulty configuration, one-shot phase manifest, e2e coverage, and stress scenario registration. - Single canonical roadmap at `docs/ROADMAP.md`; removed duplicate root, lower-case, and legacy roadmap files. - Repository-level project presentation, contribution, security, roadmap, changelog, issue template, and pull request template documentation. diff --git a/ci/e2e.sh b/ci/e2e.sh index 0214bec..b2a6d1e 100755 --- a/ci/e2e.sh +++ b/ci/e2e.sh @@ -1224,7 +1224,7 @@ record_step PASS "edge-waf-block-runtime" "403 and edge header observed for /adm bot_challenge_body="$(mktemp)" bot_challenge_status="$(curl -sS -o "$bot_challenge_body" -w '%{http_code}' -H "Host: ${TEST_DOMAIN}" -A 'Googlebot' "${EDGE_URL}/bot-check?via=edge-bot-challenge")" assert_eq "$bot_challenge_status" "403" "unverified search-bot claim should be challenged" -assert_contains "$(cat "$bot_challenge_body")" '"error":"bot_challenge_required"' "bot challenge response should be explicit" +assert_contains "$(cat "$bot_challenge_body")" "Security check" "bot challenge should return the browser verification page" rm -f "$bot_challenge_body" retry 10 1 agent_exec '/agent/push_security_events.sh' bot_event_visible=0 @@ -1269,23 +1269,42 @@ CHALLENGE_RATE_LIMIT_RULE_ID="$(json_get "$HTTP_BODY" '.data.id')" edge_pull_config challenge_codes=() challenge_response_verified=0 +challenge_body="/tmp/e2e-rate-limit-challenge-body.txt" for i in $(seq 1 8); do - code="$(curl -sS -o /tmp/e2e-rate-limit-challenge-${i}.txt -w '%{http_code}' -H "Host: ${TEST_DOMAIN}" "${EDGE_URL}/challenge?via=edge-rate-limit-challenge")" + code="$(curl -sS -o "$challenge_body" -w '%{http_code}' -H "Host: ${TEST_DOMAIN}" "${EDGE_URL}/challenge?via=edge-rate-limit-challenge")" challenge_codes+=("$code") - if [[ "$code" == "429" ]] && grep -Fq '"error":"challenge_required"' "/tmp/e2e-rate-limit-challenge-${i}.txt"; then + if [[ "$code" == "429" ]] && grep -Fq "Security check" "$challenge_body" && grep -Fq "__cdnlite_challenge_verify" "$challenge_body"; then challenge_response_verified=1 + break fi done if [[ " ${challenge_codes[*]} " != *" 429 "* ]]; then fail "challenge rate limit expected 429 challenge responses" fi -assert_eq "$challenge_response_verified" "1" "challenge rate-limit response should identify the required challenge" +assert_eq "$challenge_response_verified" "1" "challenge rate-limit response should return browser verification page" +challenge_token="$(php -r '$body=file_get_contents($argv[1]); if(preg_match("/const token = \"([^\"]+)\";/", $body, $m)){ echo $m[1]; }' "$challenge_body")" +[[ -n "$challenge_token" ]] || fail "challenge page did not include a signed token" +challenge_difficulty="$(php -r '$body=file_get_contents($argv[1]); if(preg_match("/const difficulty = ([0-9]+);/", $body, $m)){ echo $m[1]; }' "$challenge_body")" +challenge_difficulty="${challenge_difficulty:-3}" +if [[ "$challenge_difficulty" -le 1 ]]; then + challenge_pow="browser-check" +else + challenge_pow="$(php -r '$t=$argv[1]; $d=(int)$argv[2]; $prefix=str_repeat("0",$d); for($i=0;$i<5000000;$i++){ if(substr(hash("sha256",$t.":".$i),0,$d)===$prefix){ echo $i; exit(0);} } exit(1);' "$challenge_token" "$challenge_difficulty")" +fi +[[ -n "$challenge_pow" ]] || fail "could not solve local challenge proof" +cookie_jar="/tmp/e2e-cdnlite-clearance-cookie.txt" +verify_code="$(curl -sS -o /tmp/e2e-challenge-verify.txt -c "$cookie_jar" -w '%{http_code}' -H "Host: ${TEST_DOMAIN}" -X POST "${EDGE_URL}/__cdnlite_challenge_verify" --data-urlencode "token=${challenge_token}" --data-urlencode "pow=${challenge_pow}")" +assert_eq "$verify_code" "303" "valid challenge proof should set clearance and redirect" +assert_contains "$(cat "$cookie_jar")" "__cdnlite_clearance" "challenge verification should set clearance cookie" +cleared_code="$(curl -sS -b "$cookie_jar" -o /tmp/e2e-rate-limit-cleared.txt -w '%{http_code}' -H "Host: ${TEST_DOMAIN}" "${EDGE_URL}/challenge?via=edge-rate-limit-cleared")" +assert_eq "$cleared_code" "200" "valid clearance should allow matching challenge traffic to origin" +assert_contains "$(cat /tmp/e2e-rate-limit-cleared.txt)" '"origin_scheme":"https"' "cleared challenge traffic should reach origin" # Security events are delivered asynchronously by the edge agent. The event records # the configured decision (`challenge`), while the HTTP response says `challenge_required`. retry 20 1 challenge_security_event_visible api_delete "${CORE_URL}/api/v1/domains/${DOMAIN_ID}/rate-limits/${CHALLENGE_RATE_LIMIT_RULE_ID}" assert_http_status "$HTTP_CODE" "200" "challenge rate-limit cleanup failed" -record_step PASS "edge-rate-limit-challenge" "challenge action returns 429 and is visible in security events" +record_step PASS "edge-rate-limit-challenge" "challenge page, proof verification, clearance cookie, origin routing, and events verified" if docker compose exec -T edge-agent sh -lc "grep -q 'rate_limited' \"\${METRIC_PATH:-/var/lib/cdnlite/metrics.ndjson}\"" \ || docker compose exec -T edge sh -lc "grep -q 'rate_limited' /var/lib/cdnlite/metrics.ndjson"; then diff --git a/ci/phases/phase-04.yml b/ci/phases/phase-04.yml index 65055f5..7f8a2f3 100644 --- a/ci/phases/phase-04.yml +++ b/ci/phases/phase-04.yml @@ -1,7 +1,7 @@ phase: "04" name: "Real challenge and clearance system" owner: "CDNLite maintainers" -status: "in progress" +status: "complete" topology: "root docker-compose.yml" profiles: pr: @@ -56,6 +56,6 @@ evidence: stress_scenarios: - "phase4-challenge-clearance" recovery_assertions: - - "Challenge responses return signed clearance metadata instead of only denying the request." - - "Verified challenge tokens set HttpOnly scoped clearance cookies." + - "Challenge responses return a self-hosted browser proof-of-work page instead of only denying the request." + - "Verified challenge proofs set HttpOnly scoped clearance cookies." - "Block rules keep precedence over clearance cookies." diff --git a/core/app/Modules/Proxy/Services/TrafficRulesService.php b/core/app/Modules/Proxy/Services/TrafficRulesService.php index cc805ee..03a79da 100644 --- a/core/app/Modules/Proxy/Services/TrafficRulesService.php +++ b/core/app/Modules/Proxy/Services/TrafficRulesService.php @@ -1320,12 +1320,12 @@ private function protectionIntentTemplates(): array { ], 'emergency_protection' => [ 'name' => 'Emergency Protection', - 'summary' => 'Temporarily tightens site-wide limits and blocks common incident scanner patterns.', + 'summary' => 'Temporarily challenges site-wide traffic before origin routing and blocks common incident scanner patterns.', 'risk' => 'risky', 'mode' => 'confirm_first', 'rules' => [ - ['rule_table' => 'rate_limit_rules', 'template_key' => 'rate_emergency_sitewide', 'payload' => ['enabled' => true, 'priority' => 5, 'path_prefix' => '/', 'key_type' => 'ip', 'requests_per_minute' => 120, 'action' => 'block']], - ['rule_table' => 'waf_rules', 'template_key' => 'waf_emergency_scanners', 'payload' => ['enabled' => true, 'name' => 'Block emergency scanner traffic', 'priority' => 6, 'type' => 'user_agent_contains', 'pattern' => 'nikto', 'action' => 'block', 'description' => 'Generated by Emergency Protection for active incident response.', 'waf_group_id' => 'scanner_recon', 'waf_severity' => 'high', 'waf_confidence' => 'high', 'waf_safe_reason' => 'Emergency mode blocks a known scanner user agent and remains undoable.']], + ['rule_table' => 'waf_rules', 'template_key' => 'waf_emergency_scanners', 'payload' => ['enabled' => true, 'name' => 'Block emergency scanner traffic', 'priority' => 4, 'type' => 'user_agent_contains', 'pattern' => 'nikto', 'action' => 'block', 'description' => 'Generated by Emergency Protection for active incident response.', 'waf_group_id' => 'scanner_recon', 'waf_severity' => 'high', 'waf_confidence' => 'high', 'waf_safe_reason' => 'Emergency mode blocks a known scanner user agent and remains undoable.']], + ['rule_table' => 'waf_rules', 'template_key' => 'waf_emergency_sitewide_challenge', 'payload' => ['enabled' => true, 'name' => 'Challenge all visitors during attack', 'priority' => 5, 'type' => 'path_prefix', 'pattern' => '/', 'action' => 'challenge', 'description' => 'Generated by Emergency Protection to verify browsers before origin routing during an active attack.', 'waf_group_id' => 'emergency_challenge', 'waf_severity' => 'medium', 'waf_confidence' => 'medium', 'waf_safe_reason' => 'Applies temporary friction to every path until the profile is disabled.']], ], ], 'static_asset_performance' => [ diff --git a/core/tests/test_phase4_challenge_clearance_contract.py b/core/tests/test_phase4_challenge_clearance_contract.py index 5bdef7c..89fc853 100644 --- a/core/tests/test_phase4_challenge_clearance_contract.py +++ b/core/tests/test_phase4_challenge_clearance_contract.py @@ -15,17 +15,33 @@ def test_edge_challenge_issues_signed_scoped_clearance_cookie(): compose = read("docker-compose.yml") assert "ngx.hmac_sha1" in clearance + assert "resty.sha256" in clearance + assert "resty.string" in clearance assert "__cdnlite_clearance" in clearance assert "HttpOnly; SameSite=Lax" in clearance assert "CDNLITE_EDGE_CLEARANCE_SECRET" in clearance + assert "CDNLITE_EDGE_CHALLENGE_DIFFICULTY" in clearance assert "function M.issue(domain_id, action, rule_id, client_ip, ttl)" in clearance + assert "function M.issue_challenge(domain_id, action, rule_id, client_ip, return_path)" in clearance + assert "function M.verify_challenge()" in clearance assert "function M.consume_challenge(domain_id, action, rule_id, client_ip)" in clearance assert "function M.has_clearance(domain_id, action, rule_id, client_ip)" in clearance - assert "signed_clearance" in clearance + assert "Security check" in clearance + assert "browser_check" in clearance + assert "proof_of_work" in clearance + assert "invalid_browser_check" in clearance + assert "proof ~= 'browser-check'" in clearance + assert "difficulty <= 1" in clearance + assert "crypto.subtle.digest('SHA-256'" in clearance + assert "__cdnlite_challenge_verify" in clearance + assert "invalid_proof" in clearance assert "scope_matches(claims, domain_id, action, rule_id, client_ip)" in clearance assert "env CDNLITE_EDGE_CLEARANCE_SECRET;" in nginx + assert "env CDNLITE_EDGE_CHALLENGE_DIFFICULTY;" in nginx + assert "location = /__cdnlite_challenge_verify" in nginx assert "CDNLITE_EDGE_CLEARANCE_SECRET" in compose + assert "CDNLITE_EDGE_CHALLENGE_DIFFICULTY" in compose assert "local clearance = require('clearance')" in router @@ -33,18 +49,37 @@ def test_waf_and_rate_limit_challenges_are_verifiable_not_static_denies(): router = read("edge/openresty/lua/router.lua") assert "clearance.has_clearance(domain.domain_id, 'waf', rule.id, client_ip)" in router - assert "clearance.consume_challenge(domain.domain_id, 'waf', rule.id, client_ip)" in router assert "clearance.challenge_response(domain.domain_id, 'waf', rule.id, client_ip, 403, 'bot_challenge_required')" in router assert "clearance.has_clearance(domain_id, 'rate_limit', rule.id, client_ip)" in router - assert "clearance.consume_challenge(domain_id, 'rate_limit', rule.id, client_ip)" in router assert "clearance.challenge_response(domain_id, 'rate_limit', rule.id, client_ip, 429, 'challenge_required')" in router - assert "return ngx.exit(303)" in router block_index = router.index("if ngx.ctx.security_action == 'block' then") challenge_index = router.index("if ngx.ctx.security_action == 'challenge' then") assert block_index < challenge_index +def test_emergency_profile_challenges_all_visitors_before_origin(): + service = read("core/app/Modules/Proxy/Services/TrafficRulesService.php") + dashboard = read("dash/src/views/domain-tabs/DomainSecurityCenterTab.vue") + waf_tab = read("dash/src/views/domain-tabs/DomainWafTab.vue") + rate_tab = read("dash/src/views/domain-tabs/DomainRateLimitsTab.vue") + + assert "'emergency_protection'" in service + assert "'template_key' => 'waf_emergency_sitewide_challenge'" in service + assert "'type' => 'path_prefix'" in service + assert "'pattern' => '/'" in service + assert "'action' => 'challenge'" in service + assert "'priority' => 4" in service and "'priority' => 5" in service + assert "waf_emergency_scanners" in service + assert "Temporarily challenges site-wide traffic before origin routing" in service + + assert "Emergency Protection" in dashboard + assert "challenge" in waf_tab + assert "Level 1 performs a lightweight browser check" in waf_tab + assert "levels 2-6 require increasing proof-of-work" in waf_tab + assert "Challenge" in rate_tab and "hard block" in rate_tab + + def test_phase4_runner_and_stress_registration_are_present(): phase = read("ci/phase.sh") manifest = read("ci/phases/phase-04.yml") @@ -55,7 +90,7 @@ def test_phase4_runner_and_stress_registration_are_present(): assert "test_phase4_challenge_clearance_contract.py" in phase assert "phase4-challenge-clearance" in phase assert 'phase: "04"' in manifest - assert 'status: "in progress"' in manifest + assert 'status: "complete"' in manifest assert "phase4-challenge-clearance" in manifest assert "phase4-challenge-clearance" in stress assert "phase4-challenge-clearance" in scenarios diff --git a/dash/src/views/domain-tabs/DomainWafTab.vue b/dash/src/views/domain-tabs/DomainWafTab.vue index 4880bbe..53bac50 100644 --- a/dash/src/views/domain-tabs/DomainWafTab.vue +++ b/dash/src/views/domain-tabs/DomainWafTab.vue @@ -1,2 +1,2 @@ - + diff --git a/docker-compose.yml b/docker-compose.yml index 7338e9f..40b9118 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -260,6 +260,7 @@ services: CDNLITE_EDGE_DEBUG_HEADERS: ${CDNLITE_EDGE_DEBUG_HEADERS:-false} CDNLITE_EDGE_MMDB_FILE: ${CDNLITE_EDGE_MMDB_FILE:-/var/lib/cdnlite/mmdb/GeoLite2-City.mmdb} CDNLITE_EDGE_CLEARANCE_SECRET: ${CDNLITE_EDGE_CLEARANCE_SECRET:-cdnlite-local-clearance-secret} + CDNLITE_EDGE_CHALLENGE_DIFFICULTY: ${CDNLITE_EDGE_CHALLENGE_DIFFICULTY:-3} edge-agent: build: @@ -340,6 +341,7 @@ services: CDNLITE_EDGE_DEBUG_HEADERS: ${CDNLITE_EDGE_DEBUG_HEADERS:-false} CDNLITE_EDGE_MMDB_FILE: ${CDNLITE_EDGE_MMDB_FILE:-/var/lib/cdnlite/mmdb/GeoLite2-City.mmdb} CDNLITE_EDGE_CLEARANCE_SECRET: ${CDNLITE_EDGE_CLEARANCE_SECRET:-cdnlite-local-clearance-secret} + CDNLITE_EDGE_CHALLENGE_DIFFICULTY: ${CDNLITE_EDGE_CHALLENGE_DIFFICULTY:-3} edge-agent-2: build: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 461a794..ea9602b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -347,7 +347,7 @@ Destructive and high-volume tests must run only against an explicitly disposable | 1. Database architecture and real-time reporting foundation | P0 | Complete | Fast operational reads, scalable event ingestion, bounded reporting, and durable rollups | | 2. Analytics scalability and asynchronous aggregation | P0 | Complete | Bounded analytics metadata, async recalculation jobs, and idempotent aggregate upserts | | 3. Edge hot-path performance and bounded telemetry | P0 | Complete | No repeated config parsing or synchronous per-request telemetry writes | -| 4. Real challenge and clearance system | P0 | In progress | Challenge actions perform a signed clearance workflow instead of a renamed block | +| 4. Real challenge and clearance system | P0 | Complete | Challenge actions serve configurable self-hosted browser verification before origin routing | | 5. Adaptive overload protection and waiting room | P0 | Planned | Origins remain protected under attack or heavy usage | | 6. Cache correctness foundation | P0 | Planned | Standards-aware cache keys, eligibility, revalidation, and stale behavior | | 7. Origin routing, resilience, and shielding | P0 | Planned | Predictable health, load balancing, failover, retries, and circuit breaking | @@ -1451,13 +1451,13 @@ On documented reference hardware: > **One-shot completion gate:** Implement the full vertical slice, update documentation, changelog, and roadmap progress, run automated tests, clean-stack smoke, end-to-end, phase-specific stress/scale/failure/recovery, publish evidence, and only then mark the phase Complete. -**Status:** In progress +**Status:** Complete **Owner:** CDNLite maintainers **Tracking issue:** Local roadmap Phase 4 **Manifest:** `ci/phases/phase-04.yml` **Evidence:** `ci/reports/phase-04-report.md`, `ci/reports/phase-04-report.json` -**Completed work:** Added signed edge clearance tokens for WAF and rate-limit challenge actions, scoped HttpOnly clearance cookies, challenge metadata responses, invalid-token diagnostics in security events, block-before-challenge precedence, `CDNLITE_EDGE_CLEARANCE_SECRET`, Phase 4 runner registration, and stress scenario registration. -**Remaining work:** Run full clean-stack evidence before marking Complete. The current challenge is a signed clearance workflow, not a proof-of-work or CAPTCHA provider. +**Completed work:** Added configurable self-hosted browser verification pages for WAF and rate-limit challenge actions, the edge-only `/__cdnlite_challenge_verify` endpoint, signed scoped challenge and clearance tokens, level-1 lightweight browser verification, level-2-through-6 proof-of-work, HttpOnly clearance cookies, same-host redirect recovery, block-before-challenge precedence, Emergency Protection site-wide challenge mode, `CDNLITE_EDGE_CLEARANCE_SECRET`, `CDNLITE_EDGE_CHALLENGE_DIFFICULTY`, Phase 4 runner registration, e2e coverage, and stress scenario registration. +**Remaining work:** None for the accepted Phase 4 delivery. Third-party CAPTCHA providers remain a later optional extension, not a Phase 4 requirement. ### Objective diff --git a/docs/api/api.md b/docs/api/api.md index 28321e3..661d208 100644 --- a/docs/api/api.md +++ b/docs/api/api.md @@ -476,7 +476,7 @@ Protection profiles are one-click bundles over the same intent engine. One-click Managed WAF presets add inspection metadata to generated WAF rules. WAF rules created by Security Center intents can include `waf_group_id`, `waf_severity`, `waf_confidence`, and `waf_safe_reason`; edge `waf_match` security events emit matching `group_id`, `severity`, `confidence`, and `safe_reason` fields so Activity can explain why a managed rule acted. These fields are additive metadata on the advanced WAF rule, so operators can still edit, detach, or override the generated rule. -Bot Shield rules additionally carry `bot_class`, `bot_score`, and `bot_action`. A matched bot policy emits a `bot_match` security event with those fields and the request ID. A claimed search crawler is not trusted from its User-Agent alone: the built-in policy challenges it unless the edge config includes an enabled `verified_bot_sources` entry whose CIDR and User-Agent pattern both match the request. Advanced WAF rules may use `challenge`, which returns a 403 JSON `bot_challenge_required` response and records the event. +Bot Shield rules additionally carry `bot_class`, `bot_score`, and `bot_action`. A matched bot policy emits a `bot_match` security event with those fields and the request ID. A claimed search crawler is not trusted from its User-Agent alone: the built-in policy challenges it unless the edge config includes an enabled `verified_bot_sources` entry whose CIDR and User-Agent pattern both match the request. Advanced WAF and rate-limit rules may use `challenge`, which serves the self-hosted browser verification page, verifies through `/__cdnlite_challenge_verify`, sets `__cdnlite_clearance`, and records the event. `CDNLITE_EDGE_CHALLENGE_DIFFICULTY=1` uses a lightweight browser check; levels `2`-`6` add increasing proof-of-work. The Managed WAF preset catalog is read-only and returns available WAF modes, group definitions, and currently generated rule templates grouped by `waf_group_id`. Use it to inspect SQL injection, XSS, traversal, inclusion, command injection, PHP/WordPress, scanner, encoding, and bad user-agent coverage before enabling or tightening rules through the preview/apply protection flows. diff --git a/docs/security.md b/docs/security.md index dfa103e..c8f453d 100644 --- a/docs/security.md +++ b/docs/security.md @@ -60,17 +60,29 @@ listeners or error handlers. ## Challenge Clearance -WAF and rate-limit rules with the `challenge` action issue a short-lived signed -clearance challenge instead of behaving as a renamed block. The token is scoped -to the domain, action family, rule ID, client IP, and expiry. Successful -verification sets the `__cdnlite_clearance` HttpOnly cookie with `SameSite=Lax`. -Explicit block rules and administrative denies still take precedence over any -clearance cookie. +WAF and rate-limit rules with the `challenge` action serve a self-hosted browser +proof-of-work page instead of behaving as a renamed block. The challenge token +is scoped to the domain, action family, rule ID, client IP, return path, and +expiry. The browser computes a bounded SHA-256 proof and submits it to the +edge-only `/__cdnlite_challenge_verify` endpoint. Successful verification sets +the `__cdnlite_clearance` HttpOnly cookie with `SameSite=Lax` and redirects the +visitor back to the original same-host relative URL. Explicit block rules and +administrative denies still take precedence over any clearance cookie. Set `CDNLITE_EDGE_CLEARANCE_SECRET` to a strong shared secret on every edge in a fleet. Rotating it invalidates existing clearance cookies, which is safe but can temporarily make visitors solve a challenge again. +Set `CDNLITE_EDGE_CHALLENGE_DIFFICULTY` between `1` and `6` to tune challenge +cost. This is a provider-independent abuse-friction mechanism; it is not a +guarantee that a visitor is human. + +| Difficulty | Behavior | Use case | +| --- | --- | --- | +| `1` | Lightweight browser verification. The page verifies JavaScript, same-origin fetch, cookies, and redirect handling without proof-of-work. | Low-friction checks for normal sites or mild bot noise. | +| `2`-`4` | Browser verification plus increasing SHA-256 proof-of-work before clearance. | Suspicious automation, login abuse, or short attack windows. | +| `5`-`6` | High-friction proof-of-work. | Temporary emergency use during active attacks where user delay is acceptable. | + ## Authorization Limits The dashboard admin model is simple. It does not implement fine-grained RBAC, per-domain tenancy, SSO, or role-scoped permissions. Use external controls for production segmentation. diff --git a/docs/setup.md b/docs/setup.md index b0f47b7..d3e35ca 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -233,7 +233,8 @@ Edge and agent settings: | `CDNLITE_EDGE_LOG_REQUEST_BODY` | Reserved for future strict-redaction body logging; keep `false`. | | `CDNLITE_EDGE_DEBUG_HEADERS` | Reserved for future debug header logging; keep `false` unless a runbook explicitly enables it. | | `CDNLITE_EDGE_MMDB_FILE` | GeoIP MMDB used by the edge for country WAF/origin decisions; default `/var/lib/cdnlite/mmdb/GeoLite2-City.mmdb`. | -| `CDNLITE_EDGE_CLEARANCE_SECRET` | Shared edge secret for signed challenge clearance cookies. Set the same strong value on every edge; rotation invalidates existing clearances. | +| `CDNLITE_EDGE_CLEARANCE_SECRET` | Shared edge secret for signed challenge and clearance cookies. Set the same strong value on every edge; rotation invalidates existing clearances. | +| `CDNLITE_EDGE_CHALLENGE_DIFFICULTY` | Self-hosted edge challenge difficulty, from `1` to `6`; default `3`. Level `1` performs a lightweight browser check without proof-of-work. Levels `2` through `6` require increasing SHA-256 proof-of-work before origin routing. | | `EDGE_AGENT_IDLE` | CI flag to keep agent idle while scripts drive flow manually. | Recommended starting values: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5073a15..b1f2150 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -40,7 +40,7 @@ Use this order during incidents: | Dashboard cannot reach core | `VITE_CDNLITE_CORE_URL` points at an internal Compose hostname or CORS blocks origin. | Set a browser-reachable URL, rebuild dashboard, and include dashboard origin in `CDNLITE_CORS_ALLOWED_ORIGINS`. | | Login fails with known credentials | Bootstrap disabled or admin password changed. | Create a user with `cdn:admin:create` or verify bootstrap variables before first boot. | | Admins get logged out, unauthorized responses, or dashboard timeouts behind Nginx | The Core API reverse proxy is not forwarding `Authorization` or has short proxy timeouts. | Put `proxy_set_header Authorization $http_authorization;` and sane `proxy_*_timeout` values on the Core API vhost/location. The dashboard proxy alone does not fix API auth. | -| Visitors repeatedly receive challenge responses | Edge clearance cookies cannot be verified, the client IP changed, or the edge fleet uses different `CDNLITE_EDGE_CLEARANCE_SECRET` values. | Set the same strong `CDNLITE_EDGE_CLEARANCE_SECRET` on every edge, keep clocks sane, and verify the client is returning the `__cdnlite_clearance` cookie. | +| Visitors repeatedly receive challenge responses | JavaScript is disabled, the proof request is blocked, edge clearance cookies cannot be verified, the client IP changed, or the edge fleet uses different `CDNLITE_EDGE_CLEARANCE_SECRET` values. | Set the same strong `CDNLITE_EDGE_CLEARANCE_SECRET` on every edge, keep clocks sane, verify `/__cdnlite_challenge_verify` is reachable, and confirm the client is returning the `__cdnlite_clearance` cookie. | | `/ready` reports `api_token` warn | `CDNLITE_API_TOKEN` is empty in local mode. | Accept for local dev, set a strong token for production. | | `/ready` reports schema failure | Canonical schema bootstrap failed or the database is not disposable. | Inspect Core/PostgreSQL logs, then rebuild a fresh stack with `docker compose down -v && docker compose up -d --build`. | | Edge returns unknown host page | Host is not in `config.json` or edge has stale config. | Activate the domain, rebuild snapshot, and confirm edge agent pulled config. | diff --git a/edge/openresty/lua/clearance.lua b/edge/openresty/lua/clearance.lua index eb0bbbb..d118ed0 100644 --- a/edge/openresty/lua/clearance.lua +++ b/edge/openresty/lua/clearance.lua @@ -1,9 +1,13 @@ local cjson = require('cjson.safe') +local resty_sha256 = require('resty.sha256') +local resty_string = require('resty.string') local M = {} local cookie_name = '__cdnlite_clearance' local default_ttl = 1800 +local challenge_ttl = 300 +local default_difficulty = 3 local function secret() local value = os.getenv('CDNLITE_EDGE_CLEARANCE_SECRET') or '' @@ -30,15 +34,46 @@ local function sign(payload) return b64(ngx.hmac_sha1(secret(), payload)) end -local function token_payload(domain_id, action, rule_id, client_ip, expires_at, nonce) - return table.concat({ - tostring(domain_id or ''), - tostring(action or ''), - tostring(rule_id or ''), - tostring(client_ip or ''), - tostring(expires_at or ''), - tostring(nonce or ''), - }, '|') +local function hash_hex(value) + local sha = resty_sha256:new() + sha:update(value or '') + return resty_string.to_hex(sha:final()) +end + +local function html_escape(value) + return tostring(value or '') + :gsub('&', '&') + :gsub('<', '<') + :gsub('>', '>') + :gsub('"', '"') + :gsub("'", ''') +end + +local function js_string(value) + return cjson.encode(tostring(value or '')) or '""' +end + +local function same_host_path(value) + local path = tostring(value or ngx.var.request_uri or '/') + if path == '' or string.sub(path, 1, 1) ~= '/' or string.sub(path, 1, 2) == '//' then + return '/' + end + if string.find(path, '[\r\n]', 1, false) then + return '/' + end + return path +end + +local function proof_prefix(difficulty) + return string.rep('0', tonumber(difficulty or default_difficulty) or default_difficulty) +end + +local function challenge_mode(difficulty) + local n = tonumber(difficulty or default_difficulty) or default_difficulty + if n <= 1 then + return 'browser_check' + end + return 'proof_of_work' end local function encode_token(claims) @@ -113,6 +148,27 @@ function M.issue(domain_id, action, rule_id, client_ip, ttl) }), expires_at end +function M.issue_challenge(domain_id, action, rule_id, client_ip, return_path) + local expires_at = ngx.time() + challenge_ttl + local nonce = b64(tostring(ngx.now()) .. ':' .. tostring(math.random()) .. ':' .. tostring(ngx.worker.pid())) + local difficulty = tonumber(os.getenv('CDNLITE_EDGE_CHALLENGE_DIFFICULTY') or '') or default_difficulty + if difficulty < 1 then difficulty = 1 end + if difficulty > 6 then difficulty = 6 end + return encode_token({ + token_type = 'challenge', + domain_id = tostring(domain_id or ''), + action = tostring(action or ''), + rule_id = tostring(rule_id or ''), + client_ip = tostring(client_ip or ''), + return_path = same_host_path(return_path), + issued_at = ngx.time(), + expires_at = expires_at, + difficulty = difficulty, + mode = challenge_mode(difficulty), + nonce = nonce, + }), expires_at, difficulty +end + function M.consume_challenge(domain_id, action, rule_id, client_ip) local token = ngx.var.arg_cdnlite_challenge or '' if token == '' then @@ -122,6 +178,9 @@ function M.consume_challenge(domain_id, action, rule_id, client_ip) if not claims then return false, err end + if tostring(claims.token_type or 'clearance') ~= 'clearance' then + return false, 'wrong_token_type' + end if tonumber(claims.expires_at or 0) <= ngx.time() then return false, 'expired' end @@ -137,23 +196,160 @@ function M.consume_challenge(domain_id, action, rule_id, client_ip) return true end +function M.verify_challenge() + ngx.req.read_body() + local args = ngx.req.get_post_args(20) + if not args or not args.token then + args = ngx.req.get_uri_args(20) + end + local claims, err = decode_token(args.token or '') + if not claims then + ngx.status = 403 + ngx.header.content_type = 'application/json' + ngx.say(cjson.encode({ ok = false, error = err or 'invalid_challenge' })) + return ngx.exit(403) + end + if tostring(claims.token_type or '') ~= 'challenge' then + ngx.status = 403 + ngx.header.content_type = 'application/json' + ngx.say(cjson.encode({ ok = false, error = 'wrong_token_type' })) + return ngx.exit(403) + end + if tonumber(claims.expires_at or 0) <= ngx.time() then + ngx.status = 403 + ngx.header.content_type = 'application/json' + ngx.say(cjson.encode({ ok = false, error = 'expired' })) + return ngx.exit(403) + end + local client_ip = tostring(ngx.var.remote_addr or '') + if not scope_matches(claims, claims.domain_id, claims.action, claims.rule_id, client_ip) then + ngx.status = 403 + ngx.header.content_type = 'application/json' + ngx.say(cjson.encode({ ok = false, error = 'scope_mismatch' })) + return ngx.exit(403) + end + local difficulty = tonumber(claims.difficulty or default_difficulty) or default_difficulty + local proof = tostring(args.pow or '') + if difficulty <= 1 then + if proof ~= 'browser-check' then + ngx.status = 403 + ngx.header.content_type = 'application/json' + ngx.say(cjson.encode({ ok = false, error = 'invalid_browser_check' })) + return ngx.exit(403) + end + else + local digest = hash_hex(tostring(args.token or '') .. ':' .. proof) + if proof == '' or string.sub(digest, 1, difficulty) ~= proof_prefix(difficulty) then + ngx.status = 403 + ngx.header.content_type = 'application/json' + ngx.say(cjson.encode({ ok = false, error = 'invalid_proof' })) + return ngx.exit(403) + end + end + local clearance_token = encode_token({ + domain_id = tostring(claims.domain_id or ''), + action = tostring(claims.action or ''), + rule_id = tostring(claims.rule_id or ''), + client_ip = client_ip, + expires_at = ngx.time() + default_ttl, + nonce = b64(tostring(ngx.now()) .. ':' .. proof), + }) + ngx.header['Set-Cookie'] = string.format( + '%s=%s; Max-Age=%d; Path=/; HttpOnly; SameSite=Lax', + cookie_name, + clearance_token, + default_ttl + ) + ngx.header['Location'] = same_host_path(claims.return_path) + return ngx.exit(303) +end + function M.challenge_response(domain_id, action, rule_id, client_ip, status_code, error_code) - local token, expires_at = M.issue(domain_id, action, rule_id, client_ip) - local uri = tostring(ngx.var.uri or '/') - local sep = string.find(tostring(ngx.var.request_uri or ''), '?', 1, true) and '&' or '?' - local challenge_url = tostring(ngx.var.request_uri or uri) .. sep .. 'cdnlite_challenge=' .. ngx.escape_uri(token or '') + local return_path = same_host_path(ngx.var.request_uri or ngx.var.uri or '/') + local token, expires_at, difficulty = M.issue_challenge(domain_id, action, rule_id, client_ip, return_path) + local verify_path = '/__cdnlite_challenge_verify' ngx.status = status_code - ngx.header.content_type = 'application/json' + ngx.header.content_type = 'text/html; charset=utf-8' ngx.header['Cache-Control'] = 'no-store' - ngx.say(cjson.encode({ - error = error_code, - request_id = tostring(ngx.ctx.request_id or ''), - challenge = { - type = 'signed_clearance', - url = challenge_url, - expires_at = expires_at, - }, - })) + ngx.say([[ + + + + + Security check + + + +
+

Security check

+

CDNLite is checking this browser before sending traffic to the site.

+

Preparing challenge...

+
+
+ + + +]]) return ngx.exit(status_code) end diff --git a/edge/openresty/lua/router.lua b/edge/openresty/lua/router.lua index 586532c..edc68fe 100644 --- a/edge/openresty/lua/router.lua +++ b/edge/openresty/lua/router.lua @@ -245,14 +245,6 @@ local function apply_waf(cfg, host) if clearance.has_clearance(domain.domain_id, 'waf', rule.id, client_ip) then return true end - local verified, verify_err = clearance.consume_challenge(domain.domain_id, 'waf', rule.id, client_ip) - if verified then - ngx.header['Location'] = tostring(ngx.var.uri or '/') - identity.apply() - return ngx.exit(303) - elseif verify_err then - ngx.ctx.security_safe_reason = 'clearance_' .. tostring(verify_err) - end append_security_event(nil) identity.apply() return clearance.challenge_response(domain.domain_id, 'waf', rule.id, client_ip, 403, 'bot_challenge_required') @@ -350,14 +342,6 @@ local function apply_rate_limit(cfg, host, domain_id) if clearance.has_clearance(domain_id, 'rate_limit', rule.id, client_ip) then return true end - local verified, verify_err = clearance.consume_challenge(domain_id, 'rate_limit', rule.id, client_ip) - if verified then - ngx.header['Location'] = tostring(ngx.var.uri or '/') - identity.apply() - return ngx.exit(303) - elseif verify_err then - ngx.ctx.security_safe_reason = 'clearance_' .. tostring(verify_err) - end append_security_event(domain_id) edge_log.warn('rate_limit_challenge', { domain_id = tostring(domain_id or ''), rule_id = tostring(rule.id or '') }) identity.apply() diff --git a/edge/openresty/nginx.conf b/edge/openresty/nginx.conf index ad04065..b71dd9a 100644 --- a/edge/openresty/nginx.conf +++ b/edge/openresty/nginx.conf @@ -14,6 +14,7 @@ env CDNLITE_EDGE_DEBUG_HEADERS; env CDNLITE_EDGE_LOG_REQUEST_BODY; env CDNLITE_EDGE_MMDB_FILE; env CDNLITE_EDGE_CLEARANCE_SECRET; +env CDNLITE_EDGE_CHALLENGE_DIFFICULTY; error_log /dev/stderr __CDNLITE_EDGE_ERROR_LOG_LEVEL__; pid /tmp/nginx.pid; @@ -114,6 +115,17 @@ http { } } + location = /__cdnlite_challenge_verify { + default_type application/json; + content_by_lua_block { + require('clearance').verify_challenge() + } + log_by_lua_block { + local metrics = require('metrics') + metrics.on_log() + } + } + location = /__cdnlite_error_page { internal; default_type text/html; @@ -242,6 +254,17 @@ http { } } + location = /__cdnlite_challenge_verify { + default_type application/json; + content_by_lua_block { + require('clearance').verify_challenge() + } + log_by_lua_block { + local metrics = require('metrics') + metrics.on_log() + } + } + location / { access_by_lua_block { local router = require('router') From adc04d7dbc31a8bf74ff3d6c48d2d4a90255fc64 Mon Sep 17 00:00:00 2001 From: vaheeD Date: Sat, 27 Jun 2026 23:50:34 +0330 Subject: [PATCH 03/11] feat: Add challenge difficulty parameter for WAF and rate-limit rules - Introduced `challenge_difficulty` field to WAF and rate-limit rules, allowing configuration of challenge levels from 1 to 6. - Updated validation logic to ensure `challenge_difficulty` is within the specified range. - Enhanced API and database schema to support the new parameter. - Modified related tests and documentation to reflect the changes in challenge difficulty handling. --- CHANGELOG.md | 2 +- ci/e2e.sh | 27 ++++++++++++++++++- .../Controllers/TrafficRulesController.php | 12 +++++++++ .../Proxy/Services/TrafficRulesService.php | 24 +++++++++++++++-- .../migrations/000001_baseline_schema.sql | 2 ++ .../000028_challenge_difficulty.sql | 19 +++++++++++++ core/database/schema.sql | 2 ++ ...est_phase4_challenge_clearance_contract.py | 17 +++++++++--- dash/src/types.ts | 4 +-- .../views/domain-tabs/DomainRateLimitsTab.vue | 3 +++ dash/src/views/domain-tabs/DomainWafTab.vue | 2 +- docs/ROADMAP.md | 4 +-- docs/api/api.md | 2 +- docs/security.md | 8 +++--- docs/setup.md | 2 +- edge/openresty/lua/clearance.lua | 8 +++--- edge/openresty/lua/router.lua | 4 +-- 17 files changed, 119 insertions(+), 23 deletions(-) create mode 100644 core/database/migrations/000028_challenge_difficulty.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index c70de12..9c43b41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Phase 1 database architecture foundation with workload budgets, telemetry ingest batch diagnostics, reporting watermarks, reconciliation tables, current summary read model, reporting query budgets, one-shot phase runner, manifest, stress scenario, and database architecture docs. - Phase 2 analytics scalability with asynchronous recalculation jobs, job status API, idempotent aggregate upserts, bounded summary metadata, analytics cache schema, dashboard queued-job feedback, and contract documentation. - Phase 3 edge hot-path performance with worker-local config caching, last-known-good reload behavior, bounded shared-memory telemetry queues, queue health visibility, configurable edge capacity defaults, one-shot phase manifest, and stress scenario registration. -- Phase 4 real challenge and clearance system for WAF, rate-limit, and Emergency Protection flows, including a self-hosted browser proof-of-work page, edge-only verification endpoint, scoped HttpOnly clearance cookies, block-rule precedence, edge secret and difficulty configuration, one-shot phase manifest, e2e coverage, and stress scenario registration. +- Phase 4 real challenge and clearance system for WAF, rate-limit, and Emergency Protection flows, including a self-hosted browser verification page, per-rule challenge difficulty, edge-only verification endpoint, scoped HttpOnly clearance cookies, block-rule precedence, edge secret and difficulty fallback configuration, one-shot phase manifest, e2e coverage, and stress scenario registration. - Single canonical roadmap at `docs/ROADMAP.md`; removed duplicate root, lower-case, and legacy roadmap files. - Repository-level project presentation, contribution, security, roadmap, changelog, issue template, and pull request template documentation. diff --git a/ci/e2e.sh b/ci/e2e.sh index b2a6d1e..916b91c 100755 --- a/ci/e2e.sh +++ b/ci/e2e.sh @@ -1262,9 +1262,10 @@ edge_id_header_rl="$(edge_header_for_host "${TEST_DOMAIN}" "/login?via=edge-rate assert_eq "$edge_id_header_rl" "$EDGE_ID" "rate-limit response should expose edge id header" record_step PASS "edge-rate-limit-runtime" "429 observed for /login after burst" -api_post "${CORE_URL}/api/v1/domains/${DOMAIN_ID}/rate-limits" '{"enabled":true,"requests_per_minute":5,"path_prefix":"/challenge","key_type":"ip_path","priority":21,"action":"challenge"}' +api_post "${CORE_URL}/api/v1/domains/${DOMAIN_ID}/rate-limits" '{"enabled":true,"requests_per_minute":5,"path_prefix":"/challenge","key_type":"ip_path","priority":21,"action":"challenge","challenge_difficulty":3}' assert_http_status "$HTTP_CODE" "201" "challenge rate-limit create failed" CHALLENGE_RATE_LIMIT_RULE_ID="$(json_get "$HTTP_BODY" '.data.id')" +assert_contains "$HTTP_BODY" '"challenge_difficulty":3' "challenge rate-limit response should include per-rule difficulty" # The edge serves its local snapshot, so publish and apply this new rule before exercising it. edge_pull_config challenge_codes=() @@ -1306,6 +1307,30 @@ api_delete "${CORE_URL}/api/v1/domains/${DOMAIN_ID}/rate-limits/${CHALLENGE_RATE assert_http_status "$HTTP_CODE" "200" "challenge rate-limit cleanup failed" record_step PASS "edge-rate-limit-challenge" "challenge page, proof verification, clearance cookie, origin routing, and events verified" +api_post "${CORE_URL}/api/v1/domains/${DOMAIN_ID}/waf-rules" '{"enabled":true,"name":"browser-check-only","priority":22,"type":"path_prefix","pattern":"/browser-check","action":"challenge","challenge_difficulty":1,"description":"verify lightweight challenge difficulty"}' +assert_http_status "$HTTP_CODE" "201" "difficulty-1 waf challenge create failed" +DIFFICULTY_ONE_WAF_RULE_ID="$(json_get "$HTTP_BODY" '.data.id')" +assert_contains "$HTTP_BODY" '"challenge_difficulty":1' "difficulty-1 waf response should include challenge_difficulty" +edge_pull_config +difficulty_one_body="/tmp/e2e-waf-difficulty-one.txt" +difficulty_one_code="$(curl -sS -o "$difficulty_one_body" -w '%{http_code}' -H "Host: ${TEST_DOMAIN}" "${EDGE_URL}/browser-check?via=edge-waf-difficulty-one")" +assert_eq "$difficulty_one_code" "403" "difficulty-1 waf rule should serve challenge page" +assert_contains "$(cat "$difficulty_one_body")" "Security check" "difficulty-1 waf should return challenge page" +difficulty_one_value="$(php -r '$body=file_get_contents($argv[1]); if(preg_match("/const difficulty = ([0-9]+);/", $body, $m)){ echo $m[1]; }' "$difficulty_one_body")" +assert_eq "$difficulty_one_value" "1" "difficulty-1 waf challenge page should render difficulty 1" +difficulty_one_token="$(php -r '$body=file_get_contents($argv[1]); if(preg_match("/const token = \"([^\"]+)\";/", $body, $m)){ echo $m[1]; }' "$difficulty_one_body")" +[[ -n "$difficulty_one_token" ]] || fail "difficulty-1 challenge page did not include token" +difficulty_one_cookie="/tmp/e2e-difficulty-one-cookie.txt" +difficulty_one_verify="$(curl -sS -o /tmp/e2e-difficulty-one-verify.txt -c "$difficulty_one_cookie" -w '%{http_code}' -H "Host: ${TEST_DOMAIN}" -X POST "${EDGE_URL}/__cdnlite_challenge_verify" --data-urlencode "token=${difficulty_one_token}" --data-urlencode "pow=browser-check")" +assert_eq "$difficulty_one_verify" "303" "difficulty-1 browser check should set clearance" +assert_contains "$(cat "$difficulty_one_cookie")" "__cdnlite_clearance" "difficulty-1 browser check should set clearance cookie" +difficulty_one_cleared="$(curl -sS -b "$difficulty_one_cookie" -o /tmp/e2e-difficulty-one-cleared.txt -w '%{http_code}' -H "Host: ${TEST_DOMAIN}" "${EDGE_URL}/browser-check?via=edge-waf-difficulty-one-cleared")" +assert_eq "$difficulty_one_cleared" "200" "difficulty-1 clearance should allow origin routing" +assert_contains "$(cat /tmp/e2e-difficulty-one-cleared.txt)" '"origin_scheme":"https"' "difficulty-1 cleared traffic should reach origin" +api_delete "${CORE_URL}/api/v1/domains/${DOMAIN_ID}/waf-rules/${DIFFICULTY_ONE_WAF_RULE_ID}" +assert_http_status "$HTTP_CODE" "200" "difficulty-1 waf cleanup failed" +record_step PASS "edge-waf-challenge-difficulty" "path-level WAF challenge difficulty 1 used browser check and reached origin after clearance" + if docker compose exec -T edge-agent sh -lc "grep -q 'rate_limited' \"\${METRIC_PATH:-/var/lib/cdnlite/metrics.ndjson}\"" \ || docker compose exec -T edge sh -lc "grep -q 'rate_limited' /var/lib/cdnlite/metrics.ndjson"; then record_step PASS "edge-rate-limit-metrics" "rate_limited metrics emitted" diff --git a/core/app/Modules/Proxy/Http/Controllers/TrafficRulesController.php b/core/app/Modules/Proxy/Http/Controllers/TrafficRulesController.php index 094c4bc..9f62884 100644 --- a/core/app/Modules/Proxy/Http/Controllers/TrafficRulesController.php +++ b/core/app/Modules/Proxy/Http/Controllers/TrafficRulesController.php @@ -227,6 +227,10 @@ private function validateRateLimit(array $body, bool $partial): ?array { $action = Validator::enum($body, 'action', ['block', 'challenge']); if (($action['ok'] ?? false) !== true) { return $action; } } + if (array_key_exists('challenge_difficulty', $body) && $body['challenge_difficulty'] !== null && $body['challenge_difficulty'] !== '') { + $difficulty = Validator::intRange($body, 'challenge_difficulty', 1, 6); + if (($difficulty['ok'] ?? false) !== true) { return $difficulty; } + } return null; } public function createWaf(string $domainId, array $body): array { @@ -245,6 +249,10 @@ public function createWaf(string $domainId, array $body): array { $action = Validator::enum($body, 'action', ['block', 'log', 'allow', 'challenge']); if (($action['ok'] ?? false) !== true) { return $action; } } + if (array_key_exists('challenge_difficulty', $body) && $body['challenge_difficulty'] !== null && $body['challenge_difficulty'] !== '') { + $difficulty = Validator::intRange($body, 'challenge_difficulty', 1, 6); + if (($difficulty['ok'] ?? false) !== true) { return $difficulty; } + } if (array_key_exists('name', $body)) { $name = Validator::optionalString($body, 'name', 255); if (($name['ok'] ?? false) !== true) { return $name; } @@ -375,6 +383,10 @@ public function updateWaf(string $domainId, string $id, array $body): array { if (array_key_exists('action', $body) && !in_array((string) $body['action'], ['block', 'log', 'allow', 'challenge'], true)) { return ['error' => 'invalid_field', 'field' => 'action', 'detail' => 'must_be_one_of_block_log_allow_challenge', 'status' => 422]; } + if (array_key_exists('challenge_difficulty', $body) && $body['challenge_difficulty'] !== null && $body['challenge_difficulty'] !== '') { + $difficulty = Validator::intRange($body, 'challenge_difficulty', 1, 6); + if (($difficulty['ok'] ?? false) !== true) { return $difficulty; } + } if (array_key_exists('priority', $body)) { $priority = Validator::intRange($body, 'priority', 1, 100000); if (($priority['ok'] ?? false) !== true) { return $priority; } diff --git a/core/app/Modules/Proxy/Services/TrafficRulesService.php b/core/app/Modules/Proxy/Services/TrafficRulesService.php index 03a79da..23515b6 100644 --- a/core/app/Modules/Proxy/Services/TrafficRulesService.php +++ b/core/app/Modules/Proxy/Services/TrafficRulesService.php @@ -101,10 +101,16 @@ public function createWaf(string $domainId, array $in): array { 'type' => $type, 'pattern' => (string)($in['pattern'] ?? ''), 'action' => $action, + 'challenge_difficulty' => $this->challengeDifficultyValue($in), 'description' => isset($in['description']) ? (string) $in['description'] : null, ] + $this->wafMetadataPayload($in) + $this->managedRulePayload($in)); } - public function updateWaf(string $domainId, string $id, array $in): ?array { return $this->update('waf_rules', $domainId, $id, $in); } + public function updateWaf(string $domainId, string $id, array $in): ?array { + if (array_key_exists('challenge_difficulty', $in)) { + $in['challenge_difficulty'] = $this->challengeDifficultyValue($in); + } + return $this->update('waf_rules', $domainId, $id, $in); + } public function deleteWaf(string $domainId, string $id): bool { return $this->delete('waf_rules', $domainId, $id); } public function listHeaderRules(string $domainId): array { return $this->listRows('domain_header_rules', $domainId, 'priority ASC, created_at ASC'); } @@ -735,6 +741,7 @@ public function dryRunRateLimit(string $domainId, array $in): array { 'key_header_name' => $payload['key_header_name'] ?? null, 'requests_per_minute' => (int) ($payload['requests_per_minute'] ?? 60), 'action' => (string) ($payload['action'] ?? 'block'), + 'challenge_difficulty' => $payload['challenge_difficulty'] ?? null, ], 'preview_impact' => [ 'lookback_seconds' => 86400, @@ -1686,6 +1693,7 @@ private function rateLimitPayload(array $in, bool $partial = false): array { 'key_header_name' => null, 'requests_per_minute' => 60, 'action' => 'block', + 'challenge_difficulty' => null, ]; $payload = []; foreach ($defaults as $key => $default) { @@ -1693,6 +1701,8 @@ private function rateLimitPayload(array $in, bool $partial = false): array { $value = $in[$key] ?? $default; if ($key === 'key_header_name' && ($value === null || trim((string) $value) === '')) { $payload[$key] = null; + } elseif ($key === 'challenge_difficulty') { + $payload[$key] = $this->challengeDifficultyValue([$key => $value]); } else { $payload[$key] = $key === 'enabled' ? !empty($value) : ($key === 'priority' || $key === 'requests_per_minute' ? (int) $value : (string) $value); } @@ -1700,7 +1710,17 @@ private function rateLimitPayload(array $in, bool $partial = false): array { } return $payload + $this->managedRulePayload($in); } - private function cast(array $r): array { foreach(['enabled', 'preserve_query', 'respect_origin_cache_control', 'cache_authorized_requests', 'static_asset_cache_enabled', 'ignore_query_strings_for_static', 'bypass_logged_in_users', 'force_https', 'auto_renew', 'user_modified'] as $b){ if(array_key_exists($b,$r)){$r[$b]=((int)$r[$b])===1;}} foreach(['created_at','updated_at','ttl_seconds','requests_per_minute','status_code','priority','default_edge_ttl_seconds','default_browser_ttl_seconds','stale_if_error_seconds','last_generated_at','last_applied_at'] as $i){ if(isset($r[$i]) && $r[$i] !== null){$r[$i]=(int)$r[$i];}} if (array_key_exists('actions_json', $r)) { $r['actions'] = json_decode((string) $r['actions_json'], true) ?: []; } unset($r['private_key_pem']); return $r; } + private function challengeDifficultyValue(array $in): ?int { + if (!array_key_exists('challenge_difficulty', $in) || $in['challenge_difficulty'] === null || $in['challenge_difficulty'] === '') { + return null; + } + $difficulty = (int) $in['challenge_difficulty']; + if ($difficulty < 1 || $difficulty > 6) { + throw new \InvalidArgumentException('invalid_challenge_difficulty'); + } + return $difficulty; + } + private function cast(array $r): array { foreach(['enabled', 'preserve_query', 'respect_origin_cache_control', 'cache_authorized_requests', 'static_asset_cache_enabled', 'ignore_query_strings_for_static', 'bypass_logged_in_users', 'force_https', 'auto_renew', 'user_modified'] as $b){ if(array_key_exists($b,$r)){$r[$b]=((int)$r[$b])===1;}} foreach(['created_at','updated_at','ttl_seconds','requests_per_minute','status_code','priority','default_edge_ttl_seconds','default_browser_ttl_seconds','stale_if_error_seconds','last_generated_at','last_applied_at','challenge_difficulty'] as $i){ if(isset($r[$i]) && $r[$i] !== null){$r[$i]=(int)$r[$i];}} if (array_key_exists('actions_json', $r)) { $r['actions'] = json_decode((string) $r['actions_json'], true) ?: []; } unset($r['private_key_pem']); return $r; } private function castSslJob(array $r): array { foreach (['created_at', 'updated_at', 'finished_at', 'progress_percent'] as $i) { if (isset($r[$i]) && $r[$i] !== null) { diff --git a/core/database/migrations/000001_baseline_schema.sql b/core/database/migrations/000001_baseline_schema.sql index 91ac0f7..b2e2077 100644 --- a/core/database/migrations/000001_baseline_schema.sql +++ b/core/database/migrations/000001_baseline_schema.sql @@ -520,6 +520,7 @@ CREATE TABLE IF NOT EXISTS rate_limit_rules ( key_type TEXT NOT NULL DEFAULT 'ip', requests_per_minute INTEGER NOT NULL, action TEXT NOT NULL DEFAULT 'block', + challenge_difficulty INTEGER NULL CHECK (challenge_difficulty IS NULL OR (challenge_difficulty BETWEEN 1 AND 6)), created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL ); @@ -533,6 +534,7 @@ CREATE TABLE IF NOT EXISTS waf_rules ( type TEXT NOT NULL, pattern TEXT NOT NULL, action TEXT NOT NULL DEFAULT 'block', + challenge_difficulty INTEGER NULL CHECK (challenge_difficulty IS NULL OR (challenge_difficulty BETWEEN 1 AND 6)), description TEXT NULL, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL, diff --git a/core/database/migrations/000028_challenge_difficulty.sql b/core/database/migrations/000028_challenge_difficulty.sql new file mode 100644 index 0000000..bebb42d --- /dev/null +++ b/core/database/migrations/000028_challenge_difficulty.sql @@ -0,0 +1,19 @@ +ALTER TABLE waf_rules + ADD COLUMN IF NOT EXISTS challenge_difficulty INTEGER NULL; + +ALTER TABLE rate_limit_rules + ADD COLUMN IF NOT EXISTS challenge_difficulty INTEGER NULL; + +ALTER TABLE waf_rules + DROP CONSTRAINT IF EXISTS waf_rules_challenge_difficulty_range; + +ALTER TABLE waf_rules + ADD CONSTRAINT waf_rules_challenge_difficulty_range + CHECK (challenge_difficulty IS NULL OR (challenge_difficulty BETWEEN 1 AND 6)); + +ALTER TABLE rate_limit_rules + DROP CONSTRAINT IF EXISTS rate_limit_rules_challenge_difficulty_range; + +ALTER TABLE rate_limit_rules + ADD CONSTRAINT rate_limit_rules_challenge_difficulty_range + CHECK (challenge_difficulty IS NULL OR (challenge_difficulty BETWEEN 1 AND 6)); diff --git a/core/database/schema.sql b/core/database/schema.sql index 910f470..774504c 100644 --- a/core/database/schema.sql +++ b/core/database/schema.sql @@ -814,6 +814,7 @@ CREATE TABLE IF NOT EXISTS rate_limit_rules ( key_header_name TEXT NULL, requests_per_minute INTEGER NOT NULL, action TEXT NOT NULL DEFAULT 'block', + challenge_difficulty INTEGER NULL CHECK (challenge_difficulty IS NULL OR (challenge_difficulty BETWEEN 1 AND 6)), profile_id TEXT NULL, intent_id TEXT NULL, template_key TEXT NULL, @@ -834,6 +835,7 @@ CREATE TABLE IF NOT EXISTS waf_rules ( type TEXT NOT NULL, pattern TEXT NOT NULL, action TEXT NOT NULL DEFAULT 'block', + challenge_difficulty INTEGER NULL CHECK (challenge_difficulty IS NULL OR (challenge_difficulty BETWEEN 1 AND 6)), description TEXT NULL, waf_group_id TEXT NULL, waf_severity TEXT NULL, diff --git a/core/tests/test_phase4_challenge_clearance_contract.py b/core/tests/test_phase4_challenge_clearance_contract.py index 89fc853..0603a5c 100644 --- a/core/tests/test_phase4_challenge_clearance_contract.py +++ b/core/tests/test_phase4_challenge_clearance_contract.py @@ -13,6 +13,8 @@ def test_edge_challenge_issues_signed_scoped_clearance_cookie(): router = read("edge/openresty/lua/router.lua") nginx = read("edge/openresty/nginx.conf") compose = read("docker-compose.yml") + schema = read("core/database/schema.sql") + migration = read("core/database/migrations/000028_challenge_difficulty.sql") assert "ngx.hmac_sha1" in clearance assert "resty.sha256" in clearance @@ -22,7 +24,7 @@ def test_edge_challenge_issues_signed_scoped_clearance_cookie(): assert "CDNLITE_EDGE_CLEARANCE_SECRET" in clearance assert "CDNLITE_EDGE_CHALLENGE_DIFFICULTY" in clearance assert "function M.issue(domain_id, action, rule_id, client_ip, ttl)" in clearance - assert "function M.issue_challenge(domain_id, action, rule_id, client_ip, return_path)" in clearance + assert "function M.issue_challenge(domain_id, action, rule_id, client_ip, return_path, difficulty_override)" in clearance assert "function M.verify_challenge()" in clearance assert "function M.consume_challenge(domain_id, action, rule_id, client_ip)" in clearance assert "function M.has_clearance(domain_id, action, rule_id, client_ip)" in clearance @@ -43,15 +45,22 @@ def test_edge_challenge_issues_signed_scoped_clearance_cookie(): assert "CDNLITE_EDGE_CLEARANCE_SECRET" in compose assert "CDNLITE_EDGE_CHALLENGE_DIFFICULTY" in compose assert "local clearance = require('clearance')" in router + assert "challenge_difficulty INTEGER NULL CHECK" in schema + assert "ADD COLUMN IF NOT EXISTS challenge_difficulty" in migration def test_waf_and_rate_limit_challenges_are_verifiable_not_static_denies(): router = read("edge/openresty/lua/router.lua") + controller = read("core/app/Modules/Proxy/Http/Controllers/TrafficRulesController.php") + service = read("core/app/Modules/Proxy/Services/TrafficRulesService.php") assert "clearance.has_clearance(domain.domain_id, 'waf', rule.id, client_ip)" in router - assert "clearance.challenge_response(domain.domain_id, 'waf', rule.id, client_ip, 403, 'bot_challenge_required')" in router + assert "clearance.challenge_response(domain.domain_id, 'waf', rule.id, client_ip, 403, 'bot_challenge_required', rule.challenge_difficulty)" in router assert "clearance.has_clearance(domain_id, 'rate_limit', rule.id, client_ip)" in router - assert "clearance.challenge_response(domain_id, 'rate_limit', rule.id, client_ip, 429, 'challenge_required')" in router + assert "clearance.challenge_response(domain_id, 'rate_limit', rule.id, client_ip, 429, 'challenge_required', rule.challenge_difficulty)" in router + assert "Validator::intRange($body, 'challenge_difficulty', 1, 6)" in controller + assert "'challenge_difficulty' => $this->challengeDifficultyValue($in)" in service + assert "private function challengeDifficultyValue" in service block_index = router.index("if ngx.ctx.security_action == 'block' then") challenge_index = router.index("if ngx.ctx.security_action == 'challenge' then") @@ -77,6 +86,8 @@ def test_emergency_profile_challenges_all_visitors_before_origin(): assert "challenge" in waf_tab assert "Level 1 performs a lightweight browser check" in waf_tab assert "levels 2-6 require increasing proof-of-work" in waf_tab + assert "Challenge difficulty" in waf_tab + assert "Challenge difficulty" in rate_tab assert "Challenge" in rate_tab and "hard block" in rate_tab diff --git a/dash/src/types.ts b/dash/src/types.ts index cf6b080..b75951d 100644 --- a/dash/src/types.ts +++ b/dash/src/types.ts @@ -131,8 +131,8 @@ export interface ApiProtectionDiscovery { } export interface CacheRule extends ManagedRuleMetadata { id: Id; enabled: boolean; path_prefix: string; ttl_seconds: number; } export interface PurgeRequest { id: Id; domain_id?: Id; type: 'url' | 'prefix' | 'domain' | 'everything' | string; value?: string; status?: string; created_at?: number | string; updated_at?: number | string; } -export interface WafRule extends ManagedRuleMetadata { id: Id; type: string; pattern: string; action: 'block' | 'log' | 'allow' | string; priority: number; enabled?: boolean; status?: string; } -export interface RateLimitRule extends ManagedRuleMetadata { id: Id; enabled: boolean; requests_per_minute: number; priority: number; path_prefix: string; key_type: 'ip' | 'ip_path' | 'header' | 'header_path' | string; key_header_name?: string | null; action: string; } +export interface WafRule extends ManagedRuleMetadata { id: Id; type: string; pattern: string; action: 'block' | 'log' | 'allow' | string; priority: number; challenge_difficulty?: number | null; enabled?: boolean; status?: string; } +export interface RateLimitRule extends ManagedRuleMetadata { id: Id; enabled: boolean; requests_per_minute: number; priority: number; path_prefix: string; key_type: 'ip' | 'ip_path' | 'header' | 'header_path' | string; key_header_name?: string | null; action: string; challenge_difficulty?: number | null; } export interface HeaderRule extends ManagedRuleMetadata { id: Id; enabled: boolean; priority: number; operation: 'set' | 'remove' | 'append' | string; header_name: string; header_value?: string | null; path_pattern: string; } export interface IpRule extends ManagedRuleMetadata { id: Id; enabled: boolean; rule_type: 'allow' | 'block' | string; cidr: string; description?: string | null; } export interface DomainOrigin { diff --git a/dash/src/views/domain-tabs/DomainRateLimitsTab.vue b/dash/src/views/domain-tabs/DomainRateLimitsTab.vue index 65ee7af..67aed32 100644 --- a/dash/src/views/domain-tabs/DomainRateLimitsTab.vue +++ b/dash/src/views/domain-tabs/DomainRateLimitsTab.vue @@ -46,6 +46,7 @@ const helpItems = [ { title: 'Path examples', body: 'Use / for site-wide limits, /login for authentication, or /api/ for API traffic.' }, { title: 'Thresholds', body: 'Start higher than normal traffic, review analytics, then tighten for sensitive endpoints.' }, { title: 'Key type', body: 'IP groups all requests from one visitor. Header keys can limit API tokens or Authorization headers.' }, + { title: 'Challenge difficulty', body: 'Set difficulty on each challenge rule. Level 1 is lightweight browser verification; levels 2-6 add increasing proof-of-work. Leave blank to use the edge default.' }, ]; const fields = [ { key: 'enabled', label: 'Enabled', type: 'checkbox' as const, default: true, help: 'Disable instead of deleting when testing traffic impact.' }, @@ -55,6 +56,7 @@ const fields = [ { key: 'key_header_name', label: 'Header key', default: '', placeholder: 'Authorization', help: 'Required for header-based keys. Missing headers fall back to IP at the edge.' }, { key: 'priority', label: 'Priority', type: 'number' as const, default: 100, placeholder: '100', help: 'Lower priority values are evaluated first.' }, { key: 'action', label: 'Action', options: ['block', 'challenge'], default: 'block', help: 'Challenge adds friction before the limit turns into a hard block.' }, + { key: 'challenge_difficulty', label: 'Challenge difficulty', type: 'number' as const, default: '', placeholder: '1-6', help: 'Optional for challenge action. 1 is lightweight browser verification; 2-6 add increasing proof-of-work. Blank uses the edge default.' }, ]; const columns = [ { key: 'enabled', label: 'Enabled' }, @@ -62,6 +64,7 @@ const columns = [ { key: 'requests_per_minute', label: 'Requests/min' }, { key: 'key_type', label: 'Key' }, { key: 'key_header_name', label: 'Header' }, + { key: 'challenge_difficulty', label: 'Difficulty' }, { key: 'managed_by', label: 'Managed' }, { key: 'actions', label: 'Actions' }, ]; diff --git a/dash/src/views/domain-tabs/DomainWafTab.vue b/dash/src/views/domain-tabs/DomainWafTab.vue index 53bac50..2049722 100644 --- a/dash/src/views/domain-tabs/DomainWafTab.vue +++ b/dash/src/views/domain-tabs/DomainWafTab.vue @@ -1,2 +1,2 @@ - + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ea9602b..4c01a1d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1456,8 +1456,8 @@ On documented reference hardware: **Tracking issue:** Local roadmap Phase 4 **Manifest:** `ci/phases/phase-04.yml` **Evidence:** `ci/reports/phase-04-report.md`, `ci/reports/phase-04-report.json` -**Completed work:** Added configurable self-hosted browser verification pages for WAF and rate-limit challenge actions, the edge-only `/__cdnlite_challenge_verify` endpoint, signed scoped challenge and clearance tokens, level-1 lightweight browser verification, level-2-through-6 proof-of-work, HttpOnly clearance cookies, same-host redirect recovery, block-before-challenge precedence, Emergency Protection site-wide challenge mode, `CDNLITE_EDGE_CLEARANCE_SECRET`, `CDNLITE_EDGE_CHALLENGE_DIFFICULTY`, Phase 4 runner registration, e2e coverage, and stress scenario registration. -**Remaining work:** None for the accepted Phase 4 delivery. Third-party CAPTCHA providers remain a later optional extension, not a Phase 4 requirement. +**Completed work:** Added configurable self-hosted browser verification pages for WAF and rate-limit challenge actions, per-rule `challenge_difficulty` for domain paths and WAF patterns, the edge-only `/__cdnlite_challenge_verify` endpoint, signed scoped challenge and clearance tokens, level-1 lightweight browser verification, level-2-through-6 proof-of-work, HttpOnly clearance cookies, same-host redirect recovery, block-before-challenge precedence, Emergency Protection site-wide challenge mode, `CDNLITE_EDGE_CLEARANCE_SECRET`, `CDNLITE_EDGE_CHALLENGE_DIFFICULTY` fallback, Phase 4 runner registration, e2e coverage, and stress scenario registration. +**Closure:** Phase 4 has no remaining mandatory work. The completed challenge engine is self-hosted and provider-independent; third-party CAPTCHA providers may be added later as optional integrations without reopening Phase 4. ### Objective diff --git a/docs/api/api.md b/docs/api/api.md index 661d208..7f71928 100644 --- a/docs/api/api.md +++ b/docs/api/api.md @@ -476,7 +476,7 @@ Protection profiles are one-click bundles over the same intent engine. One-click Managed WAF presets add inspection metadata to generated WAF rules. WAF rules created by Security Center intents can include `waf_group_id`, `waf_severity`, `waf_confidence`, and `waf_safe_reason`; edge `waf_match` security events emit matching `group_id`, `severity`, `confidence`, and `safe_reason` fields so Activity can explain why a managed rule acted. These fields are additive metadata on the advanced WAF rule, so operators can still edit, detach, or override the generated rule. -Bot Shield rules additionally carry `bot_class`, `bot_score`, and `bot_action`. A matched bot policy emits a `bot_match` security event with those fields and the request ID. A claimed search crawler is not trusted from its User-Agent alone: the built-in policy challenges it unless the edge config includes an enabled `verified_bot_sources` entry whose CIDR and User-Agent pattern both match the request. Advanced WAF and rate-limit rules may use `challenge`, which serves the self-hosted browser verification page, verifies through `/__cdnlite_challenge_verify`, sets `__cdnlite_clearance`, and records the event. `CDNLITE_EDGE_CHALLENGE_DIFFICULTY=1` uses a lightweight browser check; levels `2`-`6` add increasing proof-of-work. +Bot Shield rules additionally carry `bot_class`, `bot_score`, and `bot_action`. A matched bot policy emits a `bot_match` security event with those fields and the request ID. A claimed search crawler is not trusted from its User-Agent alone: the built-in policy challenges it unless the edge config includes an enabled `verified_bot_sources` entry whose CIDR and User-Agent pattern both match the request. Advanced WAF and rate-limit rules may use `challenge`, which serves the self-hosted browser verification page, verifies through `/__cdnlite_challenge_verify`, sets `__cdnlite_clearance`, and records the event. Set `challenge_difficulty` from `1` to `6` on a WAF or rate-limit rule to tune that path or pattern: `1` uses a lightweight browser check, while `2`-`6` add increasing proof-of-work. If omitted, the edge uses `CDNLITE_EDGE_CHALLENGE_DIFFICULTY`. The Managed WAF preset catalog is read-only and returns available WAF modes, group definitions, and currently generated rule templates grouped by `waf_group_id`. Use it to inspect SQL injection, XSS, traversal, inclusion, command injection, PHP/WordPress, scanner, encoding, and bad user-agent coverage before enabling or tightening rules through the preview/apply protection flows. diff --git a/docs/security.md b/docs/security.md index c8f453d..bdc2e62 100644 --- a/docs/security.md +++ b/docs/security.md @@ -73,9 +73,11 @@ Set `CDNLITE_EDGE_CLEARANCE_SECRET` to a strong shared secret on every edge in a fleet. Rotating it invalidates existing clearance cookies, which is safe but can temporarily make visitors solve a challenge again. -Set `CDNLITE_EDGE_CHALLENGE_DIFFICULTY` between `1` and `6` to tune challenge -cost. This is a provider-independent abuse-friction mechanism; it is not a -guarantee that a visitor is human. +Set `challenge_difficulty` on individual WAF or rate-limit challenge rules to +tune challenge cost per domain path or pattern. If a rule omits +`challenge_difficulty`, the edge falls back to `CDNLITE_EDGE_CHALLENGE_DIFFICULTY`. +This is a provider-independent abuse-friction mechanism; it is not a guarantee +that a visitor is human. | Difficulty | Behavior | Use case | | --- | --- | --- | diff --git a/docs/setup.md b/docs/setup.md index d3e35ca..93f3123 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -234,7 +234,7 @@ Edge and agent settings: | `CDNLITE_EDGE_DEBUG_HEADERS` | Reserved for future debug header logging; keep `false` unless a runbook explicitly enables it. | | `CDNLITE_EDGE_MMDB_FILE` | GeoIP MMDB used by the edge for country WAF/origin decisions; default `/var/lib/cdnlite/mmdb/GeoLite2-City.mmdb`. | | `CDNLITE_EDGE_CLEARANCE_SECRET` | Shared edge secret for signed challenge and clearance cookies. Set the same strong value on every edge; rotation invalidates existing clearances. | -| `CDNLITE_EDGE_CHALLENGE_DIFFICULTY` | Self-hosted edge challenge difficulty, from `1` to `6`; default `3`. Level `1` performs a lightweight browser check without proof-of-work. Levels `2` through `6` require increasing SHA-256 proof-of-work before origin routing. | +| `CDNLITE_EDGE_CHALLENGE_DIFFICULTY` | Default self-hosted edge challenge difficulty, from `1` to `6`; default `3`. WAF and rate-limit challenge rules can override this per path or pattern with `challenge_difficulty`. Level `1` performs a lightweight browser check without proof-of-work. Levels `2` through `6` require increasing SHA-256 proof-of-work before origin routing. | | `EDGE_AGENT_IDLE` | CI flag to keep agent idle while scripts drive flow manually. | Recommended starting values: diff --git a/edge/openresty/lua/clearance.lua b/edge/openresty/lua/clearance.lua index d118ed0..cb829c4 100644 --- a/edge/openresty/lua/clearance.lua +++ b/edge/openresty/lua/clearance.lua @@ -148,10 +148,10 @@ function M.issue(domain_id, action, rule_id, client_ip, ttl) }), expires_at end -function M.issue_challenge(domain_id, action, rule_id, client_ip, return_path) +function M.issue_challenge(domain_id, action, rule_id, client_ip, return_path, difficulty_override) local expires_at = ngx.time() + challenge_ttl local nonce = b64(tostring(ngx.now()) .. ':' .. tostring(math.random()) .. ':' .. tostring(ngx.worker.pid())) - local difficulty = tonumber(os.getenv('CDNLITE_EDGE_CHALLENGE_DIFFICULTY') or '') or default_difficulty + local difficulty = tonumber(difficulty_override or '') or tonumber(os.getenv('CDNLITE_EDGE_CHALLENGE_DIFFICULTY') or '') or default_difficulty if difficulty < 1 then difficulty = 1 end if difficulty > 6 then difficulty = 6 end return encode_token({ @@ -264,9 +264,9 @@ function M.verify_challenge() return ngx.exit(303) end -function M.challenge_response(domain_id, action, rule_id, client_ip, status_code, error_code) +function M.challenge_response(domain_id, action, rule_id, client_ip, status_code, error_code, difficulty_override) local return_path = same_host_path(ngx.var.request_uri or ngx.var.uri or '/') - local token, expires_at, difficulty = M.issue_challenge(domain_id, action, rule_id, client_ip, return_path) + local token, expires_at, difficulty = M.issue_challenge(domain_id, action, rule_id, client_ip, return_path, difficulty_override) local verify_path = '/__cdnlite_challenge_verify' ngx.status = status_code ngx.header.content_type = 'text/html; charset=utf-8' diff --git a/edge/openresty/lua/router.lua b/edge/openresty/lua/router.lua index edc68fe..622a896 100644 --- a/edge/openresty/lua/router.lua +++ b/edge/openresty/lua/router.lua @@ -247,7 +247,7 @@ local function apply_waf(cfg, host) end append_security_event(nil) identity.apply() - return clearance.challenge_response(domain.domain_id, 'waf', rule.id, client_ip, 403, 'bot_challenge_required') + return clearance.challenge_response(domain.domain_id, 'waf', rule.id, client_ip, 403, 'bot_challenge_required', rule.challenge_difficulty) end return true end @@ -346,7 +346,7 @@ local function apply_rate_limit(cfg, host, domain_id) edge_log.warn('rate_limit_challenge', { domain_id = tostring(domain_id or ''), rule_id = tostring(rule.id or '') }) identity.apply() ngx.header['Retry-After'] = '60' - return clearance.challenge_response(domain_id, 'rate_limit', rule.id, client_ip, 429, 'challenge_required') + return clearance.challenge_response(domain_id, 'rate_limit', rule.id, client_ip, 429, 'challenge_required', rule.challenge_difficulty) end return true end From 3ee69bdd707325a030e9d52099a680bec48591ba Mon Sep 17 00:00:00 2001 From: vaheeD Date: Sun, 28 Jun 2026 00:04:53 +0330 Subject: [PATCH 04/11] feat: Enhance report warnings structure with additional fields and update related API documentation --- .../Reports/Services/ReportService.php | 11 ++++-- core/tests/test_reports_contract.py | 4 +++ dash/src/types.ts | 2 +- dash/src/views/OverviewView.vue | 34 +++++++++++++++---- docs/api/api.md | 2 +- docs/public/api/openapi.yaml | 2 +- 6 files changed, 44 insertions(+), 11 deletions(-) diff --git a/core/app/Modules/Reports/Services/ReportService.php b/core/app/Modules/Reports/Services/ReportService.php index 2430ef6..f03b68f 100644 --- a/core/app/Modules/Reports/Services/ReportService.php +++ b/core/app/Modules/Reports/Services/ReportService.php @@ -399,11 +399,18 @@ private function warnings(array $range, array $kpis): array ['key' => 'failed_jobs', 'severity' => 'critical', 'message' => 'failed jobs in range', 'link' => '/jobs'], ['key' => 'pending_dns_changes', 'severity' => 'warning', 'message' => 'pending DNS changes', 'link' => '/dns-operations'], ['key' => 'ssl_expiring_count', 'severity' => 'warning', 'message' => 'certificates expiring within 30 days', 'link' => '/domains'], - ['key' => 'origin_errors', 'severity' => 'warning', 'message' => 'origin or edge errors in range', 'link' => '/usage'], + ['key' => 'origin_errors', 'severity' => 'warning', 'message' => 'origin or edge errors in range', 'link' => '/#recent-problem-requests', 'section' => 'recent_problem_requests'], ] as $rule) { $count = (int) ($kpis[$rule['key']] ?? 0); if ($count > 0) { - $warnings[] = ['severity' => $rule['severity'], 'message' => $count . ' ' . $rule['message'], 'link' => $rule['link'], 'count' => $count]; + $warnings[] = [ + 'key' => $rule['key'], + 'severity' => $rule['severity'], + 'message' => $count . ' ' . $rule['message'], + 'link' => $rule['link'], + 'section' => $rule['section'] ?? null, + 'count' => $count, + ]; } } usort($warnings, static fn (array $a, array $b): int => ['critical' => 0, 'warning' => 1, 'info' => 2][$a['severity']] <=> ['critical' => 0, 'warning' => 1, 'info' => 2][$b['severity']]); diff --git a/core/tests/test_reports_contract.py b/core/tests/test_reports_contract.py index 7291275..e812d80 100644 --- a/core/tests/test_reports_contract.py +++ b/core/tests/test_reports_contract.py @@ -85,4 +85,8 @@ def test_dashboard_reports_client_and_overview_use_real_report_endpoints(): assert "Top Visitor Countries" in overview assert "request.client_ip" in overview assert "request.client_country" in overview + assert "'link' => '/#recent-problem-requests'" in (ROOT / "core/app/Modules/Reports/Services/ReportService.php").read_text() + assert "Show details" in overview + assert "warning.key === 'origin_errors'" in overview + assert "recent_problem_requests.slice(0, 3)" in overview assert "mock" not in overview.lower() diff --git a/dash/src/types.ts b/dash/src/types.ts index b75951d..f18ea2f 100644 --- a/dash/src/types.ts +++ b/dash/src/types.ts @@ -259,7 +259,7 @@ export interface ReportKpis { active_domains: number; online_edges: number; offline_edges: number; security_events: number; waf_blocks: number; rate_limited_requests: number; origin_errors: number; ssl_expiring_count: number; pending_dns_changes: number; failed_jobs: number; } -export interface ReportWarning { severity: 'warning' | 'critical' | 'info'; message: string; link: string; count?: number; } +export interface ReportWarning { key?: string; severity: 'warning' | 'critical' | 'info'; message: string; link: string; section?: string | null; count?: number; } export interface ReportSummary { time_range: ReportTimeRange; previous_time_range?: ReportTimeRange | null; kpis: ReportKpis; deltas?: Record | null; warnings: ReportWarning[]; generated_at: number; } export interface ReportDistributionRow { value?: string; status?: string; status_class?: string; severity?: string; action?: string; count: number; requests?: number; bytes_out?: number; } export interface ReportTraffic { diff --git a/dash/src/views/OverviewView.vue b/dash/src/views/OverviewView.vue index 0e114f2..e977225 100644 --- a/dash/src/views/OverviewView.vue +++ b/dash/src/views/OverviewView.vue @@ -32,12 +32,22 @@

Needs Attention

No active report warnings for this range.

    -
  • -
    - - {{ warning.message }} +
  • +
    +
    + + {{ warning.message }} +

    {{ warningDetail(warning) }}

    +
    + {{ warningAction(warning) }}
    - Open +
      +
    • + {{ request.status || 'error' }} + {{ request.path || request.host || 'unknown path' }} + {{ formatTime(request.ts) }} +
    • +
@@ -90,7 +100,7 @@
-
+

Recent Problem Requests

@@ -246,5 +256,17 @@ function deltaText(key: string) { return `${sign}${delta.percent}% vs previous range`; } +function warningDetail(warning: ReportSummary['warnings'][number]) { + if (warning.key === 'origin_errors') return 'See the sampled failing requests below with status, path, edge, visitor, and country.'; + if (warning.key === 'pending_dns_changes') return 'Open DNS Operations to inspect pending sync state and PowerDNS visibility.'; + if (warning.key === 'failed_jobs') return 'Open Jobs to inspect failed or cancelled SSL workflow entries.'; + return ''; +} + +function warningAction(warning: ReportSummary['warnings'][number]) { + if (warning.key === 'origin_errors') return 'Show details'; + return 'Open'; +} + onMounted(load); diff --git a/docs/api/api.md b/docs/api/api.md index 7f71928..dee2565 100644 --- a/docs/api/api.md +++ b/docs/api/api.md @@ -187,7 +187,7 @@ Useful admin login response fields: | --- | --- | --- | | `GET` | `/api/v1/overview` | Aggregate operations summary. | | `GET` | `/api/v1/overview/warnings` | Readiness and risk warnings. | -| `GET` | `/api/v1/reports/summary` | CDN operations KPIs, compare-mode deltas, and ranked warnings. Accepts `domain_id`, `from`, `to`, `bucket`, `compare`, and `limit`. | +| `GET` | `/api/v1/reports/summary` | CDN operations KPIs, compare-mode deltas, and ranked warnings with stable `key`, `link`, `section`, and `count` fields. Accepts `domain_id`, `from`, `to`, `bucket`, `compare`, and `limit`. | | `GET` | `/api/v1/reports/traffic` | Request, bandwidth, cache-ratio, status, top domain/path/visitor-country/edge, and problem-request reports. | | `GET` | `/api/v1/reports/cache` | Cache status distribution, hit-ratio trend, cache/origin bytes, uncached paths, and purge timeline. Unsupported ingest fields are returned as `null` with an `unavailable` note. | | `GET` | `/api/v1/reports/edge` | Edge online/offline counts, geography, heartbeat age, config drift, config errors, traffic, error rates, and node table. | diff --git a/docs/public/api/openapi.yaml b/docs/public/api/openapi.yaml index 8f36bf4..391cb64 100644 --- a/docs/public/api/openapi.yaml +++ b/docs/public/api/openapi.yaml @@ -140,7 +140,7 @@ paths: /api/v1/reports/summary: get: tags: [Reports] - summary: CDN operations KPIs, compare deltas, and ranked warnings + summary: CDN operations KPIs, compare deltas, and ranked warnings with stable drilldown fields parameters: - $ref: "#/components/parameters/DomainIdQuery" - $ref: "#/components/parameters/ReportFrom" From a1258ffb1482f9e89347634cee94abc8bf858175 Mon Sep 17 00:00:00 2001 From: vaheeD Date: Sun, 28 Jun 2026 00:22:32 +0330 Subject: [PATCH 05/11] feat(waiting-room): implement waiting room policy management and API endpoints - Added new API endpoints for managing waiting room policies, including retrieval, update, and emergency activation/deactivation. - Introduced a new WaitingRoomPolicy interface in TypeScript to define the structure of waiting room policies. - Created a new Vue component for the waiting room tab in the domain detail view, allowing users to manage waiting room settings. - Implemented backend logic for waiting room functionality, including ticket issuance and admission control. - Updated OpenAPI documentation to reflect new waiting room endpoints. - Added database migration for waiting room policies table. - Enhanced security documentation to include waiting room admission mechanisms. - Updated setup instructions to include new environment variables for waiting room configuration. - Added CI phase for testing waiting room features and ensuring proper functionality. --- CHANGELOG.md | 1 + ci/phases/phase-05.yml | 57 +++++ ci/smoke.sh | 7 + ci/stress/scenarios.yml | 13 + .../Controllers/TrafficRulesController.php | 56 +++++ .../Modules/Proxy/Services/ConfigService.php | 1 + .../Proxy/Services/TrafficRulesService.php | 78 +++++- .../migrations/000029_waiting_room.sql | 47 ++++ core/database/schema.sql | 48 ++++ core/public_index.php | 10 + dash/src/lib/api/waitingRoom.ts | 11 + dash/src/types.ts | 11 + dash/src/views/DomainDetailView.vue | 4 +- .../domain-tabs/DomainWaitingRoomTab.vue | 92 ++++++++ docs/ROADMAP.md | 4 +- docs/api/api.md | 1 + docs/public/api/openapi.yaml | 39 +++ docs/security.md | 9 + docs/setup.md | 1 + edge/docker-entrypoint.sh | 4 +- edge/openresty/lua/router.lua | 6 + edge/openresty/lua/waiting_room.lua | 223 ++++++++++++++++++ edge/openresty/nginx.conf | 26 ++ 23 files changed, 745 insertions(+), 4 deletions(-) create mode 100644 ci/phases/phase-05.yml create mode 100644 core/database/migrations/000029_waiting_room.sql create mode 100644 dash/src/lib/api/waitingRoom.ts create mode 100644 dash/src/views/domain-tabs/DomainWaitingRoomTab.vue create mode 100644 edge/openresty/lua/waiting_room.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c43b41..2220185 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Phase 2 analytics scalability with asynchronous recalculation jobs, job status API, idempotent aggregate upserts, bounded summary metadata, analytics cache schema, dashboard queued-job feedback, and contract documentation. - Phase 3 edge hot-path performance with worker-local config caching, last-known-good reload behavior, bounded shared-memory telemetry queues, queue health visibility, configurable edge capacity defaults, one-shot phase manifest, and stress scenario registration. - Phase 4 real challenge and clearance system for WAF, rate-limit, and Emergency Protection flows, including a self-hosted browser verification page, per-rule challenge difficulty, edge-only verification endpoint, scoped HttpOnly clearance cookies, block-rule precedence, edge secret and difficulty fallback configuration, one-shot phase manifest, e2e coverage, and stress scenario registration. +- Phase 5 waiting room with persisted per-domain policy, API and dashboard controls, signed edge queue tickets, signed admission cookies, local queue/status endpoints, bounded edge queue state, and operator documentation. - Single canonical roadmap at `docs/ROADMAP.md`; removed duplicate root, lower-case, and legacy roadmap files. - Repository-level project presentation, contribution, security, roadmap, changelog, issue template, and pull request template documentation. diff --git a/ci/phases/phase-05.yml b/ci/phases/phase-05.yml new file mode 100644 index 0000000..3c7e952 --- /dev/null +++ b/ci/phases/phase-05.yml @@ -0,0 +1,57 @@ +phase: "05" +name: "Adaptive overload protection and waiting room" +owner: "CDNLite maintainers" +status: "in_progress" +topology: "root docker-compose.yml" +profiles: + pr: + clean_required: false + destructive: false + commands: + - "docker compose config --quiet" + - "find core -name '*.php' -print0 | xargs -0 -n1 php -l" + - "luac -p edge/openresty/lua/waiting_room.lua edge/openresty/lua/router.lua" + - "bash -n ci/smoke.sh" + - "(cd dash && npm run typecheck)" + full: + clean_required: true + destructive: false + commands: + - "docker compose config --quiet" + - "find core -name '*.php' -print0 | xargs -0 -n1 php -l" + - "pytest -q core/tests" + - "sh -n edge/agent/register.sh" + - "sh -n edge/agent/heartbeat.sh" + - "sh -n edge/agent/pull_config.sh" + - "sh -n edge/agent/push_metrics.sh" + - "sh -n edge/agent/run.sh" + - "bash -n ci/smoke.sh" + - "bash -n ci/e2e.sh" + - "bash -n ci/stress-platform.sh" + - "ci/smoke.sh" + - "ci/e2e.sh" + - "ci/stress-platform.sh --scenario phase5-waiting-room" + - "ci/smoke.sh" + - "ci/e2e.sh" + - "(cd dash && npm run typecheck && npm test && npm run build)" + - "(cd docs && npm run docs:build)" + release: + clean_required: true + destructive: true + commands: + - "./ci/phase.sh 05 --profile full --clean" +required_docs: + - "docs/ROADMAP.md" + - "docs/api/api.md" + - "docs/security.md" + - "docs/setup.md" + - "CHANGELOG.md" +evidence: + json: "ci/reports/phase-05-report.json" + markdown: "ci/reports/phase-05-report.md" +stress_scenarios: + - "phase5-waiting-room" +recovery_assertions: + - "Queue endpoints respond locally and never proxy to origins." + - "Origin-bound requests stay within the configured admission budget during overload." + - "Invalid or expired queue tickets and admission cookies fail closed." diff --git a/ci/smoke.sh b/ci/smoke.sh index 006755b..1f165e2 100755 --- a/ci/smoke.sh +++ b/ci/smoke.sh @@ -146,6 +146,13 @@ rate_limit_dry_run_route_count="$(grep -c '/api/v1/domains/{domainId}/rate-limit assert_eq "$rate_limit_dry_run_route_count" "1" "rate-limit dry-run route missing" record_step PASS "schema-rate-limit-dry-run-route" "rate-limit dry-run route is registered" +waiting_room_route_count="$(grep -c '/api/v1/domains/{domainId}/waiting-room' core/public_index.php)" +if [[ "$waiting_room_route_count" -lt "4" ]]; then + fail "waiting-room API routes are incomplete" +fi +grep -Fq "cdnlite_waiting_room" edge/openresty/nginx.conf || fail "waiting-room shared dictionary missing" +record_step PASS "schema-waiting-room" "waiting-room API routes and edge state are registered" + api_protection_route_count="$(grep -c '/api/v1/domains/{domainId}/protection/api-paths' core/public_index.php)" assert_eq "$api_protection_route_count" "1" "API Protection path discovery route missing" grep -Fq "path_method_not_allowed" edge/openresty/lua/router.lua || fail "API method restriction WAF matcher missing" diff --git a/ci/stress/scenarios.yml b/ci/stress/scenarios.yml index 07167cc..97bff4f 100644 --- a/ci/stress/scenarios.yml +++ b/ci/stress/scenarios.yml @@ -55,3 +55,16 @@ scenarios: - "invalid or expired challenge tokens keep requiring a challenge" - "valid clearance redirects once and then allows matching challenge rules" - "block rules remain terminal even when a clearance cookie exists" + phase5-waiting-room: + phase: "05" + workload: "edge-waiting-room-admission" + description: "Validate overload admission, bounded local queue state, edge-only queue endpoints, and manual emergency recovery." + stress: + burst_requests: 300 + invalid_tickets: true + manual_emergency: true + queue_overflow: true + recovery: + - "manual emergency mode can be deactivated without flapping" + - "queue and admission counters stay bounded" + - "post-stress smoke and e2e gates run after overload pressure" diff --git a/core/app/Modules/Proxy/Http/Controllers/TrafficRulesController.php b/core/app/Modules/Proxy/Http/Controllers/TrafficRulesController.php index 9f62884..9e768fc 100644 --- a/core/app/Modules/Proxy/Http/Controllers/TrafficRulesController.php +++ b/core/app/Modules/Proxy/Http/Controllers/TrafficRulesController.php @@ -96,6 +96,22 @@ public function dryRunRateLimit(string $domainId, array $body): array { if ($error !== null) { return $error; } return ['data' => $this->service->dryRunRateLimit($domainId, $body)]; } + public function getWaitingRoom(string $domainId): array { + return ['data' => $this->service->getWaitingRoomPolicy($domainId)]; + } + public function updateWaitingRoom(string $domainId, array $body): array { + $error = $this->validateWaitingRoom($body); + if ($error !== null) { return $error; } + return ['data' => $this->service->updateWaitingRoomPolicy($domainId, $body)]; + } + public function activateWaitingRoomEmergency(string $domainId, array $body): array { + $ttl = Validator::intRange($body, 'ttl_seconds', 60, 86400, 3600); + if (($ttl['ok'] ?? false) !== true) { return $ttl; } + return ['data' => $this->service->activateWaitingRoomEmergency($domainId, (int) $ttl['value'], (string) ($body['reason'] ?? 'manual_emergency'))]; + } + public function deactivateWaitingRoomEmergency(string $domainId): array { + return ['data' => $this->service->deactivateWaitingRoomEmergency($domainId)]; + } public function detachManagedRule(string $domainId, string $ruleType, string $id): array { try { $rule = $this->service->detachManagedRule($domainId, $ruleType, $id); @@ -233,6 +249,46 @@ private function validateRateLimit(array $body, bool $partial): ?array { } return null; } + private function validateWaitingRoom(array $body): ?array { + foreach (['enabled'] as $field) { + if (array_key_exists($field, $body)) { + $v = Validator::bool($body, $field); + if (($v['ok'] ?? false) !== true) { return $v; } + } + } + foreach (['mode' => ['monitoring','automatic','manual'], 'state' => ['disabled','monitoring','healthy','entering_overload','overloaded','recovering','manual_emergency']] as $field => $values) { + if (array_key_exists($field, $body)) { + $v = Validator::enum($body, $field, $values); + if (($v['ok'] ?? false) !== true) { return $v; } + } + } + $ranges = [ + 'rps_threshold' => [1, 1000000], 'active_origin_threshold' => [1, 1000000], + 'origin_latency_ms_threshold' => [1, 600000], 'origin_error_rate_threshold' => [1, 100], + 'admission_rate_per_minute' => [1, 1000000], 'queue_limit' => [1, 1000000], + 'per_client_ticket_limit' => [1, 1000], 'ticket_ttl_seconds' => [30, 3600], + 'admission_ttl_seconds' => [60, 86400], 'status_poll_seconds' => [2, 300], + 'jitter_seconds' => [0, 300], 'unhealthy_windows' => [1, 100], + 'healthy_windows' => [1, 100], 'minimum_state_seconds' => [1, 86400], + 'recovery_ramp_percent' => [1, 100], 'manual_override_until' => [0, 4102444800], + ]; + foreach ($ranges as $field => [$min, $max]) { + if (array_key_exists($field, $body) && $body[$field] !== null) { + $v = Validator::intRange($body, $field, $min, $max); + if (($v['ok'] ?? false) !== true) { return $v; } + } + } + foreach (['waiting_room_title' => 120, 'waiting_room_message' => 500, 'reason' => 160] as $field => $max) { + if (array_key_exists($field, $body) && $body[$field] !== null) { + $v = Validator::optionalString($body, $field, $max); + if (($v['ok'] ?? false) !== true) { return $v; } + } + } + if (array_key_exists('trusted_cidrs', $body) && !is_array($body['trusted_cidrs'])) { + return ['error' => 'invalid_field', 'field' => 'trusted_cidrs', 'detail' => 'must_be_array', 'status' => 422]; + } + return null; + } public function createWaf(string $domainId, array $body): array { $type = Validator::requiredString($body, 'type', 64); if (($type['ok'] ?? false) !== true) { return $type; } diff --git a/core/app/Modules/Proxy/Services/ConfigService.php b/core/app/Modules/Proxy/Services/ConfigService.php index a9f7cf7..461a4f6 100644 --- a/core/app/Modules/Proxy/Services/ConfigService.php +++ b/core/app/Modules/Proxy/Services/ConfigService.php @@ -102,6 +102,7 @@ public function rebuild(?int $ifVersion = null): array foreach ($this->rules->listCachePurgeVersionsForConfig($domainId, $host) as $row) { $cachePurgeVersions[] = $row; } foreach ($this->rules->listPageRules($domainId) as $row) { if (!empty($row['enabled'])) { $row['host'] = $host; $pageRules[] = $row; } } foreach ($this->rules->listSslCertificatesForConfig($domainId, $host) as $row) { $sslCertificates[] = $row; } + foreach ($this->rules->listWaitingRoomPoliciesForConfig($domainId) as $row) { $hosts[$host]['waiting_room'] = $row; } } // Keep hash deterministic for unchanged config content. // `generated_at` is intentionally excluded so no-op syncs reuse version. diff --git a/core/app/Modules/Proxy/Services/TrafficRulesService.php b/core/app/Modules/Proxy/Services/TrafficRulesService.php index 23515b6..56f558a 100644 --- a/core/app/Modules/Proxy/Services/TrafficRulesService.php +++ b/core/app/Modules/Proxy/Services/TrafficRulesService.php @@ -717,6 +717,48 @@ public function updateRateLimit(string $domainId, string $id, array $in): ?array public function deleteRateLimit(string $domainId, string $id): bool { return $this->delete('rate_limit_rules', $domainId, $id); } + public function getWaitingRoomPolicy(string $domainId): array { + $s = Database::pdo()->prepare('SELECT * FROM waiting_room_policies WHERE domain_id=:domain_id LIMIT 1'); + $s->execute([':domain_id' => $domainId]); + $row = $s->fetch(); + if ($row) { + return $this->cast((array) $row); + } + return $this->createWaitingRoomPolicy($domainId, []); + } + public function updateWaitingRoomPolicy(string $domainId, array $in): array { + $current = $this->getWaitingRoomPolicy($domainId); + $payload = $this->waitingRoomPayload($in, true); + if ($payload === []) { + return $current; + } + $updated = $this->update('waiting_room_policies', $domainId, (string) $current['id'], $payload); + return $updated ?? $this->getWaitingRoomPolicy($domainId); + } + public function activateWaitingRoomEmergency(string $domainId, int $ttlSeconds, string $reason = 'manual_emergency'): array { + $until = time() + max(60, min(86400, $ttlSeconds)); + return $this->updateWaitingRoomPolicy($domainId, [ + 'enabled' => true, + 'mode' => 'manual', + 'state' => 'manual_emergency', + 'reason' => $reason, + 'manual_override_until' => $until, + ]); + } + public function deactivateWaitingRoomEmergency(string $domainId): array { + return $this->updateWaitingRoomPolicy($domainId, [ + 'mode' => 'monitoring', + 'state' => 'healthy', + 'reason' => 'manual_deactivation', + 'manual_override_until' => null, + ]); + } + public function listWaitingRoomPoliciesForConfig(string $domainId): array { + $s = Database::pdo()->prepare('SELECT * FROM waiting_room_policies WHERE domain_id=:domain_id AND enabled=true LIMIT 1'); + $s->execute([':domain_id' => $domainId]); + $row = $s->fetch(); + return $row ? [$this->cast((array) $row)] : []; + } public function dryRunRateLimit(string $domainId, array $in): array { $payload = $this->rateLimitPayload($in); $pathPrefix = (string) ($payload['path_prefix'] ?? '/'); @@ -1107,6 +1149,9 @@ public function undoProtectionIntent(string $domainId, string $intentId): ?array } private function listRows(string $table, string $domainId, string $orderBy = 'created_at ASC'): array { $s=Database::pdo()->prepare("SELECT * FROM {$table} WHERE domain_id=:domain_id ORDER BY {$orderBy}"); $s->execute([':domain_id'=>$domainId]); return array_map([$this,'cast'], $s->fetchAll()); } + private function createWaitingRoomPolicy(string $domainId, array $in): array { + return $this->insert('waiting_room_policies', $domainId, $this->waitingRoomPayload($in)); + } private function insert(string $table, string $domainId, array $in): array { $id=Uuid::v4(); $now=time(); $cols=array_keys($in); $names=implode(',', $cols); $bind=':'.implode(',:', $cols); $sql="INSERT INTO {$table} (id,domain_id,{$names},created_at,updated_at) VALUES (:id,:domain_id,{$bind},:created_at,:updated_at)"; @@ -1720,7 +1765,38 @@ private function challengeDifficultyValue(array $in): ?int { } return $difficulty; } - private function cast(array $r): array { foreach(['enabled', 'preserve_query', 'respect_origin_cache_control', 'cache_authorized_requests', 'static_asset_cache_enabled', 'ignore_query_strings_for_static', 'bypass_logged_in_users', 'force_https', 'auto_renew', 'user_modified'] as $b){ if(array_key_exists($b,$r)){$r[$b]=((int)$r[$b])===1;}} foreach(['created_at','updated_at','ttl_seconds','requests_per_minute','status_code','priority','default_edge_ttl_seconds','default_browser_ttl_seconds','stale_if_error_seconds','last_generated_at','last_applied_at','challenge_difficulty'] as $i){ if(isset($r[$i]) && $r[$i] !== null){$r[$i]=(int)$r[$i];}} if (array_key_exists('actions_json', $r)) { $r['actions'] = json_decode((string) $r['actions_json'], true) ?: []; } unset($r['private_key_pem']); return $r; } + private function waitingRoomPayload(array $in, bool $partial = false): array { + $defaults = [ + 'enabled' => false, 'mode' => 'monitoring', 'state' => 'disabled', 'reason' => null, + 'rps_threshold' => 100, 'active_origin_threshold' => 20, 'origin_latency_ms_threshold' => 3000, + 'origin_error_rate_threshold' => 50, 'admission_rate_per_minute' => 60, 'queue_limit' => 1000, + 'per_client_ticket_limit' => 3, 'ticket_ttl_seconds' => 300, 'admission_ttl_seconds' => 900, + 'status_poll_seconds' => 5, 'jitter_seconds' => 4, 'unhealthy_windows' => 3, 'healthy_windows' => 3, + 'minimum_state_seconds' => 60, 'recovery_ramp_percent' => 25, 'manual_override_until' => null, + 'trusted_cidrs_json' => '[]', 'waiting_room_title' => 'Traffic is high', + 'waiting_room_message' => 'You are in a short waiting room while this site protects its origin.', + 'counters_json' => '{}', + ]; + $payload = $partial ? [] : $defaults; + foreach ($defaults as $key => $default) { + if (!array_key_exists($key, $in)) { + continue; + } + $value = $in[$key]; + if ($key === 'trusted_cidrs_json' && is_array($value)) { + $value = json_encode(array_values(array_slice($value, 0, 100)), JSON_UNESCAPED_SLASHES); + } + if ($key === 'counters_json' && is_array($value)) { + $value = json_encode($value, JSON_UNESCAPED_SLASHES); + } + $payload[$key] = $value; + } + if (array_key_exists('trusted_cidrs', $in)) { + $payload['trusted_cidrs_json'] = json_encode(array_values(array_slice((array) $in['trusted_cidrs'], 0, 100)), JSON_UNESCAPED_SLASHES); + } + return $payload; + } + private function cast(array $r): array { foreach(['enabled', 'preserve_query', 'respect_origin_cache_control', 'cache_authorized_requests', 'static_asset_cache_enabled', 'ignore_query_strings_for_static', 'bypass_logged_in_users', 'force_https', 'auto_renew', 'user_modified'] as $b){ if(array_key_exists($b,$r)){$r[$b]=in_array($r[$b], [true, 1, '1', 't', 'true'], true);}} foreach(['created_at','updated_at','ttl_seconds','requests_per_minute','status_code','priority','default_edge_ttl_seconds','default_browser_ttl_seconds','stale_if_error_seconds','last_generated_at','last_applied_at','challenge_difficulty','rps_threshold','active_origin_threshold','origin_latency_ms_threshold','origin_error_rate_threshold','admission_rate_per_minute','queue_limit','per_client_ticket_limit','ticket_ttl_seconds','admission_ttl_seconds','status_poll_seconds','jitter_seconds','unhealthy_windows','healthy_windows','minimum_state_seconds','recovery_ramp_percent','manual_override_until'] as $i){ if(isset($r[$i]) && $r[$i] !== null){$r[$i]=(int)$r[$i];}} if (array_key_exists('actions_json', $r)) { $r['actions'] = json_decode((string) $r['actions_json'], true) ?: []; } if (array_key_exists('trusted_cidrs_json', $r)) { $r['trusted_cidrs'] = json_decode((string) $r['trusted_cidrs_json'], true) ?: []; unset($r['trusted_cidrs_json']); } if (array_key_exists('counters_json', $r)) { $r['counters'] = json_decode((string) $r['counters_json'], true) ?: []; unset($r['counters_json']); } unset($r['private_key_pem']); return $r; } private function castSslJob(array $r): array { foreach (['created_at', 'updated_at', 'finished_at', 'progress_percent'] as $i) { if (isset($r[$i]) && $r[$i] !== null) { diff --git a/core/database/migrations/000029_waiting_room.sql b/core/database/migrations/000029_waiting_room.sql new file mode 100644 index 0000000..90723e0 --- /dev/null +++ b/core/database/migrations/000029_waiting_room.sql @@ -0,0 +1,47 @@ +CREATE TABLE IF NOT EXISTS waiting_room_policies ( + id TEXT PRIMARY KEY, + domain_id TEXT NOT NULL UNIQUE REFERENCES domains(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT false, + mode TEXT NOT NULL DEFAULT 'monitoring', + state TEXT NOT NULL DEFAULT 'disabled', + reason TEXT NULL, + rps_threshold INTEGER NOT NULL DEFAULT 100, + active_origin_threshold INTEGER NOT NULL DEFAULT 20, + origin_latency_ms_threshold INTEGER NOT NULL DEFAULT 3000, + origin_error_rate_threshold INTEGER NOT NULL DEFAULT 50, + admission_rate_per_minute INTEGER NOT NULL DEFAULT 60, + queue_limit INTEGER NOT NULL DEFAULT 1000, + per_client_ticket_limit INTEGER NOT NULL DEFAULT 3, + ticket_ttl_seconds INTEGER NOT NULL DEFAULT 300, + admission_ttl_seconds INTEGER NOT NULL DEFAULT 900, + status_poll_seconds INTEGER NOT NULL DEFAULT 5, + jitter_seconds INTEGER NOT NULL DEFAULT 4, + unhealthy_windows INTEGER NOT NULL DEFAULT 3, + healthy_windows INTEGER NOT NULL DEFAULT 3, + minimum_state_seconds INTEGER NOT NULL DEFAULT 60, + recovery_ramp_percent INTEGER NOT NULL DEFAULT 25, + manual_override_until BIGINT NULL, + trusted_cidrs_json JSONB NOT NULL DEFAULT '[]'::jsonb, + waiting_room_title TEXT NOT NULL DEFAULT 'Traffic is high', + waiting_room_message TEXT NOT NULL DEFAULT 'You are in a short waiting room while this site protects its origin.', + counters_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + CHECK (mode IN ('monitoring','automatic','manual')), + CHECK (state IN ('disabled','monitoring','healthy','entering_overload','overloaded','recovering','manual_emergency')), + CHECK (rps_threshold BETWEEN 1 AND 1000000), + CHECK (active_origin_threshold BETWEEN 1 AND 1000000), + CHECK (origin_latency_ms_threshold BETWEEN 1 AND 600000), + CHECK (origin_error_rate_threshold BETWEEN 1 AND 100), + CHECK (admission_rate_per_minute BETWEEN 1 AND 1000000), + CHECK (queue_limit BETWEEN 1 AND 1000000), + CHECK (per_client_ticket_limit BETWEEN 1 AND 1000), + CHECK (ticket_ttl_seconds BETWEEN 30 AND 3600), + CHECK (admission_ttl_seconds BETWEEN 60 AND 86400), + CHECK (status_poll_seconds BETWEEN 2 AND 300), + CHECK (jitter_seconds BETWEEN 0 AND 300), + CHECK (unhealthy_windows BETWEEN 1 AND 100), + CHECK (healthy_windows BETWEEN 1 AND 100), + CHECK (minimum_state_seconds BETWEEN 1 AND 86400), + CHECK (recovery_ramp_percent BETWEEN 1 AND 100) +); diff --git a/core/database/schema.sql b/core/database/schema.sql index 774504c..2859bf5 100644 --- a/core/database/schema.sql +++ b/core/database/schema.sql @@ -826,6 +826,54 @@ CREATE TABLE IF NOT EXISTS rate_limit_rules ( updated_at BIGINT NOT NULL ); +CREATE TABLE IF NOT EXISTS waiting_room_policies ( + id TEXT PRIMARY KEY, + domain_id TEXT NOT NULL UNIQUE REFERENCES domains(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT false, + mode TEXT NOT NULL DEFAULT 'monitoring', + state TEXT NOT NULL DEFAULT 'disabled', + reason TEXT NULL, + rps_threshold INTEGER NOT NULL DEFAULT 100, + active_origin_threshold INTEGER NOT NULL DEFAULT 20, + origin_latency_ms_threshold INTEGER NOT NULL DEFAULT 3000, + origin_error_rate_threshold INTEGER NOT NULL DEFAULT 50, + admission_rate_per_minute INTEGER NOT NULL DEFAULT 60, + queue_limit INTEGER NOT NULL DEFAULT 1000, + per_client_ticket_limit INTEGER NOT NULL DEFAULT 3, + ticket_ttl_seconds INTEGER NOT NULL DEFAULT 300, + admission_ttl_seconds INTEGER NOT NULL DEFAULT 900, + status_poll_seconds INTEGER NOT NULL DEFAULT 5, + jitter_seconds INTEGER NOT NULL DEFAULT 4, + unhealthy_windows INTEGER NOT NULL DEFAULT 3, + healthy_windows INTEGER NOT NULL DEFAULT 3, + minimum_state_seconds INTEGER NOT NULL DEFAULT 60, + recovery_ramp_percent INTEGER NOT NULL DEFAULT 25, + manual_override_until BIGINT NULL, + trusted_cidrs_json JSONB NOT NULL DEFAULT '[]'::jsonb, + waiting_room_title TEXT NOT NULL DEFAULT 'Traffic is high', + waiting_room_message TEXT NOT NULL DEFAULT 'You are in a short waiting room while this site protects its origin.', + counters_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + CHECK (mode IN ('monitoring','automatic','manual')), + CHECK (state IN ('disabled','monitoring','healthy','entering_overload','overloaded','recovering','manual_emergency')), + CHECK (rps_threshold BETWEEN 1 AND 1000000), + CHECK (active_origin_threshold BETWEEN 1 AND 1000000), + CHECK (origin_latency_ms_threshold BETWEEN 1 AND 600000), + CHECK (origin_error_rate_threshold BETWEEN 1 AND 100), + CHECK (admission_rate_per_minute BETWEEN 1 AND 1000000), + CHECK (queue_limit BETWEEN 1 AND 1000000), + CHECK (per_client_ticket_limit BETWEEN 1 AND 1000), + CHECK (ticket_ttl_seconds BETWEEN 30 AND 3600), + CHECK (admission_ttl_seconds BETWEEN 60 AND 86400), + CHECK (status_poll_seconds BETWEEN 2 AND 300), + CHECK (jitter_seconds BETWEEN 0 AND 300), + CHECK (unhealthy_windows BETWEEN 1 AND 100), + CHECK (healthy_windows BETWEEN 1 AND 100), + CHECK (minimum_state_seconds BETWEEN 1 AND 86400), + CHECK (recovery_ramp_percent BETWEEN 1 AND 100) +); + CREATE TABLE IF NOT EXISTS waf_rules ( id TEXT PRIMARY KEY, domain_id TEXT NOT NULL, diff --git a/core/public_index.php b/core/public_index.php index 0a960fb..14bd365 100644 --- a/core/public_index.php +++ b/core/public_index.php @@ -561,6 +561,16 @@ function requireApiAuth(): void $router->add('PATCH', '/api/v1/domains/{domainId}/rate-limits/{ruleId}', static fn (Request $req, array $p) => Response::json($rulesController->updateRateLimit((string) $p['domainId'], (string) $p['ruleId'], $req->body)), auth: true); $router->add('POST', '/api/v1/domains/{domainId}/rate-limits/{ruleId}/detach-managed', static fn (Request $req, array $p) => Response::json($rulesController->detachManagedRule((string) $p['domainId'], 'rate_limit', (string) $p['ruleId'])), auth: true); $router->add('DELETE', '/api/v1/domains/{domainId}/rate-limits/{ruleId}', static fn (Request $req, array $p) => Response::json($rulesController->deleteRateLimit((string) $p['domainId'], (string) $p['ruleId'])), auth: true); +$router->add('GET', '/api/v1/domains/{domainId}/waiting-room', static fn (Request $req, array $p) => Response::json($rulesController->getWaitingRoom((string) $p['domainId'])), auth: true); +$router->add('PATCH', '/api/v1/domains/{domainId}/waiting-room', static function (Request $req, array $p) use ($rulesController): array { + $result = $rulesController->updateWaitingRoom((string) $p['domainId'], $req->body); + return Response::json($result, (int) ($result['status'] ?? 200)); +}, auth: true); +$router->add('POST', '/api/v1/domains/{domainId}/waiting-room/emergency/activate', static function (Request $req, array $p) use ($rulesController): array { + $result = $rulesController->activateWaitingRoomEmergency((string) $p['domainId'], $req->body); + return Response::json($result, (int) ($result['status'] ?? 200)); +}, auth: true); +$router->add('POST', '/api/v1/domains/{domainId}/waiting-room/emergency/deactivate', static fn (Request $req, array $p) => Response::json($rulesController->deactivateWaitingRoomEmergency((string) $p['domainId'])), auth: true); $router->add('POST', '/api/v1/domains/{domainId}/waf-rules', static fn (Request $req, array $p) => Response::json($rulesController->createWaf((string) $p['domainId'], $req->body), 201), auth: true); $router->add('GET', '/api/v1/domains/{domainId}/waf-rules', static fn (Request $req, array $p) => Response::json($rulesController->listWaf((string) $p['domainId'])), auth: true); $router->add('PATCH', '/api/v1/domains/{domainId}/waf-rules/{wafId}', static fn (Request $req, array $p) => Response::json($rulesController->updateWaf((string) $p['domainId'], (string) $p['wafId'], $req->body)), auth: true); diff --git a/dash/src/lib/api/waitingRoom.ts b/dash/src/lib/api/waitingRoom.ts new file mode 100644 index 0000000..2deadbe --- /dev/null +++ b/dash/src/lib/api/waitingRoom.ts @@ -0,0 +1,11 @@ +import { api } from './client'; +import type { WaitingRoomPolicy } from '@/types'; + +export const waitingRoomApi = { + get: (domainId: string) => api.get(`/api/v1/domains/${domainId}/waiting-room`), + update: (domainId: string, input: Partial) => api.patch(`/api/v1/domains/${domainId}/waiting-room`, input), + activateEmergency: (domainId: string, input: { ttl_seconds: number; reason?: string }) => + api.post(`/api/v1/domains/${domainId}/waiting-room/emergency/activate`, input), + deactivateEmergency: (domainId: string) => + api.post(`/api/v1/domains/${domainId}/waiting-room/emergency/deactivate`), +}; diff --git a/dash/src/types.ts b/dash/src/types.ts index f18ea2f..f1ee06c 100644 --- a/dash/src/types.ts +++ b/dash/src/types.ts @@ -133,6 +133,17 @@ export interface CacheRule extends ManagedRuleMetadata { id: Id; enabled: boolea export interface PurgeRequest { id: Id; domain_id?: Id; type: 'url' | 'prefix' | 'domain' | 'everything' | string; value?: string; status?: string; created_at?: number | string; updated_at?: number | string; } export interface WafRule extends ManagedRuleMetadata { id: Id; type: string; pattern: string; action: 'block' | 'log' | 'allow' | string; priority: number; challenge_difficulty?: number | null; enabled?: boolean; status?: string; } export interface RateLimitRule extends ManagedRuleMetadata { id: Id; enabled: boolean; requests_per_minute: number; priority: number; path_prefix: string; key_type: 'ip' | 'ip_path' | 'header' | 'header_path' | string; key_header_name?: string | null; action: string; challenge_difficulty?: number | null; } +export interface WaitingRoomPolicy { + id: Id; domain_id: Id; enabled: boolean; mode: 'monitoring' | 'automatic' | 'manual' | string; + state: 'disabled' | 'monitoring' | 'healthy' | 'entering_overload' | 'overloaded' | 'recovering' | 'manual_emergency' | string; + reason?: string | null; rps_threshold: number; active_origin_threshold: number; + origin_latency_ms_threshold: number; origin_error_rate_threshold: number; admission_rate_per_minute: number; + queue_limit: number; per_client_ticket_limit: number; ticket_ttl_seconds: number; admission_ttl_seconds: number; + status_poll_seconds: number; jitter_seconds: number; unhealthy_windows: number; healthy_windows: number; + minimum_state_seconds: number; recovery_ramp_percent: number; manual_override_until?: number | null; + trusted_cidrs: string[]; waiting_room_title: string; waiting_room_message: string; + counters?: Record; created_at?: number; updated_at?: number; +} export interface HeaderRule extends ManagedRuleMetadata { id: Id; enabled: boolean; priority: number; operation: 'set' | 'remove' | 'append' | string; header_name: string; header_value?: string | null; path_pattern: string; } export interface IpRule extends ManagedRuleMetadata { id: Id; enabled: boolean; rule_type: 'allow' | 'block' | string; cidr: string; description?: string | null; } export interface DomainOrigin { diff --git a/dash/src/views/DomainDetailView.vue b/dash/src/views/DomainDetailView.vue index 22f72b8..49528d9 100644 --- a/dash/src/views/DomainDetailView.vue +++ b/dash/src/views/DomainDetailView.vue @@ -99,7 +99,7 @@ import { computed, onMounted, ref, watch } from 'vue'; import { RouterLink, useRoute, useRouter } from 'vue-router'; import { Activity, BarChart3, Database, FileClock, Gauge, Globe2, LockKeyhole, - ListFilter, Network, RefreshCw, Route, ServerCog, ShieldCheck, ShieldHalf, SlidersHorizontal, + ListFilter, Network, RefreshCw, Route, ServerCog, ShieldCheck, ShieldHalf, SlidersHorizontal, TimerReset, } from 'lucide-vue-next'; import EmptyState from '@/components/ui/EmptyState.vue'; import HorizontalScrollFrame from '@/components/ui/HorizontalScrollFrame.vue'; @@ -125,6 +125,7 @@ import DomainIpRulesTab from './domain-tabs/DomainIpRulesTab.vue'; import DomainOriginsTab from './domain-tabs/DomainOriginsTab.vue'; import DomainActivityTab from './domain-tabs/DomainActivityTab.vue'; import DomainSecurityCenterTab from './domain-tabs/DomainSecurityCenterTab.vue'; +import DomainWaitingRoomTab from './domain-tabs/DomainWaitingRoomTab.vue'; const route = useRoute(); const router = useRouter(); @@ -153,6 +154,7 @@ const wafTabs = [ { key: 'ip-access', label: 'IP Access', icon: ListFilter, component: DomainIpRulesTab }, { key: 'headers', label: 'Headers', icon: SlidersHorizontal, component: DomainHeadersTab }, { key: 'rate-limits', label: 'Rate Limits', icon: Gauge, component: DomainRateLimitsTab }, + { key: 'waiting-room', label: 'Waiting Room', icon: TimerReset, component: DomainWaitingRoomTab }, ]; const secondaryTabs = [ { key: 'analytics', label: 'Analytics', icon: BarChart3, component: DomainAnalyticsTab }, diff --git a/dash/src/views/domain-tabs/DomainWaitingRoomTab.vue b/dash/src/views/domain-tabs/DomainWaitingRoomTab.vue new file mode 100644 index 0000000..1f41f65 --- /dev/null +++ b/dash/src/views/domain-tabs/DomainWaitingRoomTab.vue @@ -0,0 +1,92 @@ +