carlos / platform Public

spec: edge request protection design, draft for builder check

Probe and secret blocking before wake, OWASP CRS PL1 via Coraza in
watch-then-block, tenant rules per app (file and console union), request
records, under-attack proof of work. Rate limits defer to #358.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev c5b8a133445aa5ddcebf8877d9faf5c0953f304c parent b612e71
1 file changed, +542 −0
  • docs/superpowers/specs/2026-10-24-edge-request-protection-design.md +542 −0

CI cancelled — superseded by a newer push — run details

CI log
=== gate ===
.amadan/test-gate.sh
=== launchctx (1 s)
ok  	amadan.net/carlos/platform.git/launchctx	0.013s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
=== vet (18 s)
=== gofmt (1 s)
gofmt -l .: clean
=== rest (171 s)
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	3.782s
ok  	github.com/carlosframework/platform/cmd/moveprobe	11.838s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.766s
ok  	github.com/carlosframework/platform/internal/activator	58.074s
ok  	github.com/carlosframework/platform/internal/agentsock	0.060s
ok  	github.com/carlosframework/platform/internal/alerting	0.008s
ok  	github.com/carlosframework/platform/internal/analytics	0.031s
ok  	github.com/carlosframework/platform/internal/appconfig	2.871s
ok  	github.com/carlosframework/platform/internal/apperr	0.150s
ok  	github.com/carlosframework/platform/internal/applog	0.217s
ok  	github.com/carlosframework/platform/internal/awsinv	0.004s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.006s
ok  	github.com/carlosframework/platform/internal/boxid	0.868s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.014s
ok  	github.com/carlosframework/platform/internal/caps	0.016s
ok  	github.com/carlosframework/platform/internal/certwarm	0.005s
ok  	github.com/carlosframework/platform/internal/cgroup	0.785s
ok  	github.com/carlosframework/platform/internal/changelog	0.516s
ok  	github.com/carlosframework/platform/internal/claimactivation	1.346s
ok  	github.com/carlosframework/platform/internal/clamd	0.075s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	8.263s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.013s
ok  	github.com/carlosframework/platform/internal/dbquery	0.013s
ok  	github.com/carlosframework/platform/internal/dbreads	2.162s
?   	github.com/carlosframework/platform/internal/dbreads/dbreadstest	[no test files]
ok  	github.com/carlosframework/platform/internal/dbverify	0.005s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.006s
ok  	github.com/carlosframework/platform/internal/docsite	0.014s
ok  	github.com/carlosframework/platform/internal/domains	2.809s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.007s
ok  	github.com/carlosframework/platform/internal/edge	45.669s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.008s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.164s
ok  	github.com/carlosframework/platform/internal/gate	1.727s
ok  	github.com/carlosframework/platform/internal/genselect	0.024s
ok  	github.com/carlosframework/platform/internal/geo	0.023s
ok  	github.com/carlosframework/platform/internal/geoip	0.036s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.292s
ok  	github.com/carlosframework/platform/internal/host	142.050s
ok  	github.com/carlosframework/platform/internal/hostactors	6.281s
ok  	github.com/carlosframework/platform/internal/hostactors/awsmembership	0.016s
ok  	github.com/carlosframework/platform/internal/hostbox	3.094s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.779s
ok  	github.com/carlosframework/platform/internal/hostenv	0.016s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.016s
ok  	github.com/carlosframework/platform/internal/hostexec	5.401s
ok  	github.com/carlosframework/platform/internal/hostfence	0.040s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.386s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.017s
ok  	github.com/carlosframework/platform/internal/hostguard	1.076s
ok  	github.com/carlosframework/platform/internal/hostops	2.005s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.016s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.016s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.622s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.229s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.018s
ok  	github.com/carlosframework/platform/internal/infraview	0.017s
ok  	github.com/carlosframework/platform/internal/instancectl	5.254s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.825s
ok  	github.com/carlosframework/platform/internal/instancefs	0.022s
ok  	github.com/carlosframework/platform/internal/instancekey	0.018s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.025s
ok  	github.com/carlosframework/platform/internal/layout	1.790s
ok  	github.com/carlosframework/platform/internal/lbhealth	0.015s
ok  	github.com/carlosframework/platform/internal/lease	10.353s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.612s
ok  	github.com/carlosframework/platform/internal/ledger	0.027s
ok  	github.com/carlosframework/platform/internal/mail	0.231s
ok  	github.com/carlosframework/platform/internal/maintenance	0.094s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.600s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.107s
ok  	github.com/carlosframework/platform/internal/metrics	0.009s
ok  	github.com/carlosframework/platform/internal/moves	22.539s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.956s
ok  	github.com/carlosframework/platform/internal/objstore	0.281s
ok  	github.com/carlosframework/platform/internal/ops	0.239s
ok  	github.com/carlosframework/platform/internal/park	0.300s
ok  	github.com/carlosframework/platform/internal/plans	0.012s
ok  	github.com/carlosframework/platform/internal/privatefile	0.011s
ok  	github.com/carlosframework/platform/internal/provision	24.575s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.009s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.594s
ok  	github.com/carlosframework/platform/internal/relay	7.220s
ok  	github.com/carlosframework/platform/internal/release	26.455s
ok  	github.com/carlosframework/platform/internal/rollout	3.903s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.007s
ok  	github.com/carlosframework/platform/internal/rootaws	0.009s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.061s
ok  	github.com/carlosframework/platform/internal/rootstate	0.026s
ok  	github.com/carlosframework/platform/internal/runs	0.568s
ok  	github.com/carlosframework/platform/internal/s3compat	0.005s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.674s
ok  	github.com/carlosframework/platform/internal/seeds	0.014s
ok  	github.com/carlosframework/platform/internal/sim/engines	2.598s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	11.460s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.462s
ok  	github.com/carlosframework/platform/internal/status	0.114s
ok  	github.com/carlosframework/platform/internal/steering	0.008s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.316s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.006s
ok  	github.com/carlosframework/platform/internal/termfence	0.005s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.005s
ok  	github.com/carlosframework/platform/internal/unixdial	0.002s
ok  	github.com/carlosframework/platform/internal/updatewords	0.005s
ok  	github.com/carlosframework/platform/internal/usage	0.005s
ok  	github.com/carlosframework/platform/internal/version	0.004s
ok  	github.com/carlosframework/platform/internal/vet	1.689s
=== internal_console@0 (81 s)
ok  	github.com/carlosframework/platform/internal/console	79.203s
=== internal_console@1 (44 s)
ok  	github.com/carlosframework/platform/internal/console	42.055s
=== internal_console@2 (42 s)
ok  	github.com/carlosframework/platform/internal/console	40.048s
=== internal_console@3 (42 s)
ok  	github.com/carlosframework/platform/internal/console	40.291s
=== internal_console@4 (43 s)
ok  	github.com/carlosframework/platform/internal/console	40.985s
=== internal_console@5 (42 s)
ok  	github.com/carlosframework/platform/internal/console	40.246s
=== internal_console@6 (38 s)
ok  	github.com/carlosframework/platform/internal/console	36.116s
=== internal_console@7 (46 s)
ok  	github.com/carlosframework/platform/internal/console	44.170s
=== root@0 (110 s)
ok  	github.com/carlosframework/platform	108.137s
=== root@1 (84 s)
ok  	github.com/carlosframework/platform	82.485s
=== root@2 (93 s)
ok  	github.com/carlosframework/platform	91.104s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64

amadan: job cancelled — a newer push superseded it
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64

amadan: job cancelled — a newer push superseded it
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64

amadan: job cancelled — a newer push superseded it
diff --git a/docs/superpowers/specs/2026-10-24-edge-request-protection-design.md b/docs/superpowers/specs/2026-10-24-edge-request-protection-design.md
new file mode 100644
index 000000000..b21702aaa
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-24-edge-request-protection-design.md
@@ -0,0 +1,542 @@
+# Edge request protection: probe blocking, attack rules and tenant rules
+
+**Date:** 2026-10-24 · **Status:** draft, awaiting Paul's read · **Requested
+by:** Paul, in conversation ("Should CARLOS platform have the equivalent of a
+WAF built in?", then "Can we get a more extensive database of well known probe
+paths? Can we do 80/20 on OWASP style stuff? Can we allow tenants to define
+their own rules per app?").
+
+**Builds on:** the password gate's route-property path
+(`docs/superpowers/specs/2026-09-07-password-gate-design.md`), the edge rate
+limit (`docs/superpowers/specs/2026-10-24-edge-rate-limit-design.md` on branch
+`design-edge-rate-limit-358`, amadan #358, approved), the edge's trusted client
+address (`clientIP`, `internal/edge/analytics.go`), per-app log shipping
+(`internal/applog`, whose `StreamEdge` "per-request access lines (stage 2)" is
+declared and never emitted), the static serving class (`internal/edge/static.go`)
+and GeoIP (#241).
+
+Facts below about current code were checked on origin/main b612e710.
+
+## 1. What this is for
+
+Paul wants CARLOS to protect every app it hosts against the common attacks
+before carloku.com is advertised more widely. There have been many security
+incidents in the industry lately, and "every app sits behind sensible
+protection by default" is something the platform should be able to say.
+
+It is a platform feature. carloku.com gets it as one tenant among many, and a
+self-hosted platform gets it with no extra service.
+
+**The overriding priority, in Paul's words: a misfire is worse than a miss,
+but both hurt.** So the design aims for high-precision blocking, not timid
+watching: block by default only what is near-certain, earn every other default
+from real traffic, and make a misfire cheap to fix (a reference code on the
+block page, the reason visible to the developer, a one-line exception that
+takes effect in seconds).
+
+## 2. Decisions Paul made (2026-10-24, in conversation)
+
+1. **Not a full WAF; a platform-shaped layer.** No managed signature WAF in
+ front of the boxes, no IP reputation lists. The edge gets protections that
+ fit a shared, multi-tenant front door.
+2. **Both outcomes hurt; precision over timidity.** Near-certain rules block by
+ default. Everything else runs in watch mode first and is promoted to
+ blocking only when real traffic shows it does not misfire.
+3. **Cheap to fix.** Every block carries a reference code; the developer can see
+ which rule fired and add an exception in one line, live within seconds.
+4. **Tenant rules are written by the app's developer**, typically reacting to a
+ known attack that may be under way. They must **take effect within seconds,
+ without a deploy**.
+5. **Rules can be written in a file and in the console: a union** (Paul: "Can
+ we do both or either or a union?"). Each rule records where it came from;
+ pushing the file never touches console rules (§6.2).
+6. **Serve a probe-looking path if the app really has it** (Paul: "if /wp-admin
+ exists we should serve it"). Blocking a path the app would 404 anyway costs
+ the visitor nothing; the design checks existence wherever it can (§5.3).
+7. **Except obvious security leaks: block and warn, with a hard override**
+ (Paul: "block and warn for obvious security vulnerabilities that would
+ require a hard override"). `/.env`, `/.git/` and friends are blocked even
+ when the file exists, the developer is warned at deploy time, and serving
+ one needs a deliberate override for one exact path (§5.2).
+8. **Rate limits are #358's**, already designed and approved: per instance,
+ applying only while the instance is busy, plan-bounded on carloku.com. This
+ design adds only a probe-specific limit (§5.5) and does not redesign them.
+ (In conversation I offered "rate-limit" as a tenant rule action; that is
+ withdrawn in favour of #358's per-instance path exceptions.)
+
+## 3. Evidence (recon, 2026-10-24)
+
+**CARLOS records nothing per request today.** Analytics keeps a path only for
+200 `text/html` page views; errors only 5xx; the usage meter only counts; the
+edge access stream is declared but never written; journald on the boxes holds
+TLS handshake and proxy errors only, and on the busiest box covers about 12
+hours because replica-sync INFO lines fill it. So the evidence below came from
+the legacy (non-CARLOS) stack of a self-hosted deployment with more real
+traffic, which sits behind a managed cloud WAF: 7 days of CDN WAF logs (13.4M
+requests) and the access logs of a load balancer with no healthy targets (pure
+background scanning, 14.5k requests).
+
+**What attackers ask for** (about 55k probe requests in 7 days):
+
+| Target | Hits | Distinct paths | Leading paths |
+|---|---|---|---|
+| Secrets and dotfiles | 17.5k | 2,825 | `/.git/config`, `/.env` and dozens of `.env.*` variants, `/<dir>/.env` for ~40 dirs, `/.aws/credentials`, `/.docker/config.json`, `/.git-credentials`, `/docker-compose.yml`, `/@fs/.env` |
+| PHP scripts and webshells | 12.5k | 2,015 | `/admin.php`, `/info.php`, `/phpinfo.php`, `/_profiler/phpinfo`, long tail of shell names |
+| Backups and dumps | 11.0k | 1,528 | `/db.sql`, `*.php.save`, `*.zip`, nothing over ~40 each |
+| WordPress | 7.9k | 1,219 | `/wordpress/`, `/wp-json/` under many prefixes, `/wp-login.php`, `/xmlrpc.php`, plugin file managers |
+| Admin panels | ~4.0k | 210 | `/admin/login`, `/admin`, `/admin/settings` |
+| Smaller groups | 1.6k | | PHP frameworks (`/vendor/phpunit`, `/_ignition`, `/telescope`, ThinkPHP `invokefunction`), Java/Spring (`/actuator`, `.action`, `.do`, `.jsp`), phpMyAdmin, Node/Vite dev (`/@fs/`), VPN/Exchange, CGI/router/IoT (`/cgi-bin/luci`, `/setup.cgi`, `/HNAP1`, `/GponForm`), `/server-status`, cloud metadata |
+
+Newer targets: `/_debugbar`, `/vendor/composer/installed.json`, `/Dockerfile`,
+`/gcp-credentials.json`, `/firebase-adminsdk.json`, `POST /mcp`, `/dns-query`.
+
+**Attack payloads were rare:** ~830 path traversal / file inclusion, 175
+server-side request forgery (cloud metadata URLs, `file:///proc/self/environ`),
+119 SQL injection (one source), ~50 PHP injection, 8 shell injection, 4 XSS,
+zero Log4Shell. The managed WAF let **every** SQL injection through (its common
+rule set has no SQL rules) and allowed 84 Vite `/@fs/` and `/proc/self/environ`
+requests.
+
+**Who sends it:** 55-65% of hostile traffic claims to be a browser, so
+user-agent rules catch little. One source sent 2,946 requests in a minute across
+~1,300 distinct probe paths; a limit on "not found" responses per client would
+stop that.
+
+**Probes cost CARLOS wakes.** Any request to a hibernating route wakes the
+instance unless the response cache answers it. In the same 7 days the flagship
+had 3,683 wakes (3,161 with no scheduled job within 90 s, across 96 hosts) and
+the self-hosted deployment 1,205 (1,196 unscheduled). They can't be attributed
+to bots without request records, but every probe on a known host costs a wake.
+
+**What misfired on real visitors** (the most important finding):
+
+1. **IP reputation lists**: ~22% of sampled reputation-list blocks were real
+ visitors (browser fetch headers, same-site Referer, a mobile app) loading an
+ embed widget, sign-in assets and event images. Roughly 4k real requests a
+ week.
+2. **Bad-bot user-agent rule**: 65.5k blocks, nearly all a legitimate SEO
+ crawler many site owners want.
+3. **Cookie and query-string XSS rules, URI-size rule**: fired on real widget
+ loads, a native app's signed image URL (random base64 that looked like XSS)
+ and a long signed image-proxy URL.
+4. **SQL words in real paths** a naive matcher would block:
+ `…-vat-in-the-european-union`, `…display-a-selection-of-tickets`,
+ `…update-the-widget-from-version-1`, `…how-do-i-delete-a-hub`,
+ `…export-attendees-from-all-events`, `…embeddable-javascript-widget`.
+5. **"admin" in real paths**: `/assets/admin/application-<hash>.js` (380 hits,
+ 281 sources), `favicon-admin-*.png`, a help article about the admin pane.
+6. **"wp-" and "pma"**: ~560 real hotlinked `/wp-content/uploads/…png` loads a
+ week; a customer upload named `PMA_FULL_LOGO…` matched as phpMyAdmin.
+7. **Random base64** in signed URL tokens containing `eval`, `drop`, `union`,
+ `exec`, `sleep` (176 requests a week).
+8. **URLs inside query parameters** (OAuth `redirect_uri=https://…`), ~600 a
+ week: a naive SSRF rule would break sign-in.
+9. **`..`, quotes and backslashes** in real URLs: a cross-domain analytics
+ parameter ending `..`, image filenames containing `..`, broken escaped links
+ `/en/articles/\"https:/…\"`.
+
+**From a tenant app's own abuse protection** (read-only review of its source):
+it uses no user-agent checks, IP reputation, header-consistency scoring or
+fingerprinting, and its design notes reject them because an attacker passes by
+copying one accepted request. It relies on rate limits that slow rather than
+block (429 with Retry-After, penalties that grow then decay), IPv6 counted per
+/64, proof of work instead of CAPTCHAs, refusals tallied in memory and flushed
+in batches rather than written per request, and leaving ordinary page views and
+crawlers alone. Misfires it caught before and after launch: a "form filled in
+too fast" check refused decisive people; a honeypot field named `website` was
+filled by password managers; checkout steps that never received the spam-check
+token refused real buyers. Its rate limits were broken twice by trusting the
+wrong client address. The edge's `clientIP` already gets this right.
+
+## 4. Goals, non-goals, anti-goals
+
+**Goals**
+
+- Request records good enough to calibrate rules, explain any block from its
+ reference code, and show a developer what a rule would have caught.
+- Hard blocks for obvious leaks, on by default for every app.
+- A maintained probe catalogue, blocked only where the app would not serve the
+ path, before any wake.
+- A limit on probe bursts that never penalises a shared address's real users.
+- The OWASP core rule set's 80/20: its lowest-strictness rules, watch first,
+ then blocking for the ones that earn it.
+- Tenant rules per app: written in a file or the console, live in seconds,
+ undone as fast.
+- A per-app "under attack" switch using a proof-of-work challenge.
+
+**Non-goals (v1)**
+
+- Rate limiting in general (#358 owns it).
+- Volumetric floods and DDoS from many addresses (the cloud provider's network
+ layer).
+- IP reputation lists, user-agent blocking, browser fingerprinting, CAPTCHAs.
+- Response inspection (data-leak detection in what the app sends back).
+- Tenant rules that match request bodies (v1 matches the request line, headers,
+ client address and country; the OWASP rules do inspect bodies).
+- Automatic "under attack" triggering.
+- Bot management for good bots (verifying crawlers); good bots are simply not
+ targeted.
+
+**Anti-goals: it fails if any of these happen, however well it works otherwise**
+
+- A real visitor is blocked and nobody can tell why: no reference code, or the
+ developer cannot find the rule from it.
+- A developer cannot stop a misfire within a minute, without a deploy.
+- A blocked probe wakes a sleeping instance or reaches it at all.
+- A rule matches on a word inside a path (`union`, `admin`, `wp-`) rather than
+ on path structure or parsed syntax.
+- One app's rule affects another app.
+- Request records store passwords, tokens, cookie values or full client
+ addresses, or cost a storage write per request.
+- It adds latency a visitor would notice on an ordinary page view.
+- A self-hosted platform needs an AWS service, a plan catalog or anything
+ carloku.com-specific to use it (provider-neutral core).
+
+## 5. Platform protections
+
+### 5.1 Tiers
+
+Every platform rule belongs to one tier:
+
+| Tier | Default | When the path exists in the app | Override |
+|---|---|---|---|
+| **Hard** (§5.2): obvious leaks and traversal | block | still blocked, and the developer is warned at deploy time | hard override, one exact path |
+| **Probe** (§5.3): technology-specific probe paths | block, when the app would not serve the path | served | ordinary allow rule |
+| **Attack** (§5.4): OWASP core rule set, lowest strictness | watch, then block per rule once calibrated | n/a | ordinary allow rule, by rule id and scope |
+
+Hard and probe blocks answer `404` with a short plain body and the reference
+code in a response header and the body. *My call:* a 404 is what the app would
+have said for a path it does not have, so a real visitor who hits one loses
+nothing, and a scanner learns nothing. Attack and tenant blocks answer `403`
+with a small page naming the platform as the blocker and showing the reference
+code. Its wording goes through copy review when built.
+
+### 5.2 Hard tier
+
+Blocked for every app, whether or not the file exists, case-insensitively, on
+the decoded and dot-segment-resolved path, at any depth:
+
+- `.env` and `.env.*` path segments.
+- `.git`, `.svn`, `.hg` segments; `.git-credentials`.
+- `.aws`, `.ssh`, `.docker`, `.kube` segments; `.npmrc`, `.pypirc`,
+ `.netrc`, `.htpasswd`.
+- Private key and credential files: `id_rsa`, `id_ed25519` and similar, and the
+ credential-file names the evidence shows (`credentials.json`,
+ `gcp-credentials.json`, `firebase-adminsdk*.json`), as whole segments only.
+- Database dumps by extension: `.sql`, `.sql.gz`, `.sqlite`, `.db` dumps at a
+ path, and backup suffixes on source files (`*.php.save`, `*.bak`, `*.old`)
+ where the evidence supports them.
+- Path traversal: any `..` segment after percent-decoding (including `%2e%2e`,
+ `%2f` and `%5c` separators). Browsers resolve dot segments before sending, so
+ a real visitor never sends one. Filenames merely containing `..` (`photo..jpg`)
+ are not segments and pass.
+- `/proc/self/` anywhere in the decoded path, and Vite's `/@fs/` dev door.
+
+`/.well-known/` is exempt as a whole: it is a legitimate dot-prefixed path (ACME,
+`security.txt`, app links).
+
+*My call:* the exact list is the builder's, drawn from §3 and the appendix,
+under one rule: a hard entry must be something no deployed app should ever
+serve to the public. Anything a real app might legitimately serve goes in the
+probe tier instead.
+
+**Deploy-time warning.** When a release contains a file matching the hard tier,
+the deploy reply names each one and says it will not be served. Where the
+platform can see the file tree (static sites; rootfs images once #366 lands),
+the check is exact. For other app kinds there is nothing to inspect and no
+warning.
+
+**Hard override.** Serving one of these needs a separate, explicitly named
+command (or console action) for one exact path on one app. It cannot take a
+prefix or pattern. It is logged with who set it, and the console shows a
+standing warning on the app while it exists.
+
+### 5.3 Probe tier and existence
+
+The probe catalogue groups paths by the technology they target: WordPress, PHP
+(scripts and webshells), phpMyAdmin, PHP frameworks, Java/Spring, CGI and
+routers, server-status pages, cloud metadata, admin panels. Each entry matches
+**path structure**, never a word inside a path: an exact path, a path prefix on
+segment boundaries, or a final-segment extension. `/wp-content/uploads/x.png`
+is not `/wp-login.php`, and `/assets/admin/app.js` is not `/admin/login`.
+
+A probe block only fires when the app would not have served the path. How the
+edge knows:
+
+- **Static sites: exact.** The edge holds the site's file tree
+ (`internal/edge/static.go`). A path that is in the tree is served.
+- **Framework apps: the route list.** The framework publishes the app's routes
+ in the release manifest at build time and the edge checks against it. A
+ catch-all route (a single-page app answering every path) counts as unknown,
+ not as "everything exists". This is a request to the CARLOS framework
+ (filed separately; §9), not something the platform guesses.
+- **Everything else** (other binaries; rootfs apps once #366 lands, which can
+ genuinely be WordPress): unknown. Three answers:
+ - A **per-app technology switch** ("this app is PHP", "this app is WordPress")
+ turns off the matching groups for that app. For rootfs apps, *my call:*
+ detect the obvious cases at deploy time (a `wp-config.php`, `*.php` files)
+ and set the switch automatically, telling the developer.
+ - **Admin panels are never blocked on an unknown app.** `/admin` is a common
+ real route. That group only feeds the probe-burst limit (§5.5).
+ - **Learning during calibration.** While a group is in watch mode fleet-wide
+ (§8), probe-looking requests are forwarded and the response recorded. A
+ 2xx or 3xx answer exempts that path prefix for that app automatically and
+ tells the developer. Learned exemptions persist after blocking starts.
+
+**The catalogue** is a versioned data file in the platform repo, seeded from §3
+and the appendix, plus public scanner wordlists where their licence allows. It
+is refreshed from our own request records once those exist, and ships with
+releases. *My call:* the file format and where it lives are the builder's.
+
+Probe and hard checks run **before the activator and before the cache**, as the
+password gate does (`internal/edge/edge.go:1688`), so a probe never wakes an
+instance and a stale cache entry can never serve a leak.
+
+### 5.4 Attack tier: the OWASP 80/20
+
+*My call:* use **Coraza** (the OWASP Go engine, Apache 2.0) with the **OWASP Core
+Rule Set at paranoia level 1**, in anomaly-scoring mode, rather than writing our
+own detectors. CRS's SQL and XSS detection parse syntax (libinjection-style),
+which is exactly what §3's misfire list demands: `european-union` is not SQL.
+
+- **A benchmark gate comes first.** The builder measures added latency and
+ memory at the edge on representative traffic before anything else. If Coraza
+ cannot meet a budget a visitor would not notice on an ordinary page view, stop
+ and report back rather than switching to a home-grown matcher. *My call for
+ the budget:* under 1 ms added at p50 for a GET with no body.
+- **Score threshold.** CRS blocks when a request's combined anomaly score crosses
+ a threshold. *My call:* run calibration at the CRS default and record every
+ score, so the promoted threshold is chosen from data.
+- **Bodies.** Inspect request bodies up to a cap (*my call:* 128 KiB), skip file
+ contents in multipart uploads, and pass anything beyond the cap uninspected
+ rather than buffering it. A body cap never refuses a request by itself.
+- **Per-rule promotion.** Every CRS rule starts in watch mode. After calibration
+ (§8), the rules that matched nothing legitimate become block-by-default for
+ every app. The rest stay watch-only; an app can opt into blocking them.
+- **Per-app strictness.** An app can turn the attack tier off, keep the
+ platform default, or opt into blocking every PL1 rule. *My call:* higher
+ paranoia levels are not offered in v1.
+- **Placement.** After the cache lookup (a cache hit never reaches the app, so
+ an attack payload in it is harmless), immediately before the activator or
+ proxy, next to #358's check.
+- **Updates.** CRS is pinned to a version in the platform and updated through
+ ordinary releases.
+
+### 5.5 Probe-burst limit
+
+A client that collects many "not found" answers on one app in a short window
+(hard and probe blocks, and the app's own 404s) is a scanner working through a
+list. Once it crosses the threshold, its further requests **to paths in the
+probe catalogue (including admin panels and the unknown-existence groups)** are
+refused for a cool-down, without a wake. Its other requests pass.
+
+This stops bulk probing cold without ever refusing a shared address's real
+users their real pages, honouring #358's ruling that a shared address is never
+limited pre-emptively. *My call:* 60 not-founds in 60 s, a 10-minute cool-down,
+keyed by `clientIP` truncated as #358 does (/32 IPv4, /64 IPv6); the builder
+checks both numbers against request records before shipping.
+
+### 5.6 The "under attack" switch
+
+A per-app switch, set by CLI or console and live within seconds, for an incident:
+
+- A browser navigation without a valid pass gets a small interstitial page that
+ solves a SHA-256 proof-of-work puzzle in the background and continues. The
+ pass is a signed, short-lived cookie bound to the host. No CAPTCHA.
+- Difficulty is set by the server, sealed into the challenge, and rises with
+ load.
+- Cache hits are served without a challenge (they cost the instance nothing).
+- Paths the developer marks as non-browser (APIs, webhooks) are not challenged.
+ #358's limits still apply to them.
+- Visitors without JavaScript see a plain explanation. This is an emergency
+ mode, and that cost is accepted.
+
+*My call:* other cookieless non-navigation requests (fetches without a pass) get
+`429` with `Retry-After`. The builder settles the exact navigation test (for
+example `Sec-Fetch-Mode: navigate`, or `Accept: text/html` on a GET) and the pass
+lifetime.
+
+## 6. Tenant rules
+
+### 6.1 What a rule is
+
+Rules belong to an **app** and apply to every instance of it, optionally scoped
+to environments (`registry.Route.Environment`). A rule has:
+
+- **Match:** any combination of method, path (exact, prefix, or RE2 regular
+ expression), query parameter name or value, request header (user agent
+ included), client address or range, and country (GeoIP, #241). Request
+ bodies are out of v1.
+- **Action:**
+ - **block**: refuse with the tenant-block page and a reference code.
+ - **watch**: count and record only.
+ - **allow**: an exception. It names what it exempts (a probe group, an attack
+ rule id, a tenant rule by name, or everything except the hard tier) within
+ its match scope. An allow can never exempt the hard tier.
+- **A name**, unique within the app, so records, the console and allow rules
+ can refer to it.
+
+*My call:* a small, readable rule syntax of our own, not ModSecurity's SecLang,
+which is powerful but hard to read and easy to get wrong. The builder designs
+it. Illustration only:
+
+```
+block bad-export path ^/api/export country != GB
+watch odd-agent header User-Agent ~ "sqlmap|nikto"
+allow search-sql rule 942100 path /search
+```
+
+**Order:** the hard tier first (only a hard override suppresses it), then allow
+rules, then tenant blocks, probe tier and attack tier. A matching allow
+suppresses exactly what it names.
+
+**Rate limits are not a tenant rule action.** Per-path rate settings live in
+#358's per-instance path exceptions.
+
+### 6.2 File and console: a union
+
+The platform stores one rule set per app. Every rule records its origin:
+**file** or **console**.
+
+- **The file** lives in the app's repo. One CLI command pushes it; the push
+ replaces exactly the app's file-origin rules and leaves console rules alone.
+ So a rule someone added in the console during an incident survives the next
+ push.
+- **The console** lists all of an app's rules. File rules are read-only there
+ (edit them in the repo). Console rules can be added, edited and deleted in
+ place. "Copy as file text" on a console rule gives the line to paste into
+ the file once the emergency is over; when the file containing it is pushed,
+ the console rule of the same name is retired.
+- **Deploys never touch rules.** A deploy can never quietly undo an emergency
+ rule.
+- **History:** every change (who, when, origin, before and after) is recorded
+ and visible on the app.
+
+*My call:* when a file push introduces a rule whose name matches a live console
+rule, the file rule replaces it and the reply says so. Two different rules with
+the same name across origins are never both live.
+
+### 6.3 Live in seconds
+
+Rules are validated at the console (syntax, RE2 compile, size limits, scope
+belongs to the app) and refused there with a precise error, never half-applied.
+Accepted rules converge to every box serving the app within seconds, by the
+same route-property path the password gate uses and the change index made fast
+(`docs/superpowers/specs/2026-10-21-s3-change-index-design.md`). Deleting a
+rule is just as fast. *My call:* limits of 200 rules per app and 4 KiB per
+regular expression; the builder may adjust them from benchmarks.
+
+### 6.4 Seeing what a rule does
+
+- **A new rule can start in watch mode**, and the CLI reply and console show
+ its match counts directly. Nobody has to go looking for a report. (A tenant
+ app that tried a shadow-measurement phase found nobody read the reports.)
+- **Backtest:** where request records hold the fields a rule matches on, the
+ push reply says how many of the last 7 days' requests it would have matched,
+ with a few examples. *My call:* this is in v1 only if records carry the
+ fields cheaply. Otherwise it is the first follow-up.
+
+## 7. Request records
+
+This comes first: without it nothing can be calibrated, explained or
+backtested.
+
+- **Blocked and watched requests** each get a record: time, reference code,
+ host, app, environment, tier, rule id or name, action, method, path, query
+ parameter **names**, user-agent family, country, a truncated client address
+ (/24 IPv4, /48 IPv6) plus a keyed hash of the full key for correlation, and
+ the matched fragment.
+- **Redaction:** the matched fragment is truncated (*my call:* 64 bytes) and
+ omitted entirely when it comes from a cookie, an `Authorization` header, or a
+ parameter whose name looks secret (`password`, `token`, `secret`, `key`,
+ `session` and similar).
+- **Everything else** is counted in memory per app, per minute (status class,
+ method, probe-catalogue hits, wake caused or not) and flushed in batches. No
+ storage write per request, ever, and a flood must not turn records into its
+ own load: under pressure records are sampled, and the counts say how many
+ were dropped.
+- **Shipping** uses the existing per-app log pipeline (`internal/applog`,
+ `StreamEdge`) and its budgets, so they are readable with `carlos logs` and in
+ the console. *My call:* 14 days' retention.
+- **Reference codes** are short, unguessable, and resolve (for the app's members
+ only) to the record: which rule fired, on what, and the one-line exception
+ that would allow it.
+- **Calibration needs more:** during the watch window (§8), probe-catalogue and
+ attack-tier matches also record the upstream response status, so learned
+ exemptions (§5.3) and promotion decisions have the evidence.
+
+Fixing the replica-sync log volume that limits journald retention to ~12 hours
+on the busiest box is a separate, small piece of work, worth doing alongside.
+
+## 8. Rollout and calibration
+
+1. **Records first**, on dev boxes, then the flagship.
+2. **Hard tier and probe tier in watch mode** fleet-wide on the flagship (and
+ any self-hosted deployment that takes the release) for a calibration window.
+ *My call:* at least 14 days, ended by a written report.
+3. **Promotion by review.** We read every distinct (rule, path) match class in
+ the report, assisted by flags for likely-real traffic (a same-site Referer,
+ browser fetch headers, the same client also getting real pages). A rule or
+ catalogue entry goes to block-by-default only if nothing in its class looks
+ legitimate. Paul approves the promotion list.
+4. **Tenant rules** ship once records and the convergence path are in place.
+5. **Attack tier** follows the same watch, report, review and promotion cycle,
+ after its benchmark gate.
+6. **Under attack** last.
+
+Each step is its own implementation plan, rolled dev first, then the flagship.
+
+## 9. Requests to other pieces
+
+- **CARLOS framework:** publish the app's route list in the release manifest at
+ build time, with catch-all routes marked (§5.3). Filed as a request on the
+ framework, not worked around.
+- **Rootfs apps (#366):** the deploy step needs to expose the image's file list
+ (or the platform's scan of it) for the hard-tier warning and the PHP and
+ WordPress switch.
+- **#358:** shares the client key, the place in the edge and possibly the
+ per-host counters. The two must not double-count or disagree on the key.
+
+## 10. Left to the builder
+
+- The exact hard-tier list and the probe catalogue's format, location and
+ matching implementation, within §5.2 and §5.3's rules.
+- The rule syntax (§6.1), the CLI verbs and console screens. All user-facing
+ words (verbs, flags, block pages, deploy warnings, console text) go through
+ copy review.
+- Where exactly each check sits in `ServeHTTP`, within the constraints stated:
+ hard and probe checks before the activator and the cache; attack checks after
+ the cache and before the activator or proxy; every check evaluated exactly
+ once per request, including requests forwarded between boxes, on a box that
+ holds the app's current rules.
+- The calibration report's format.
+- Every number marked *my call*, checked against benchmarks and records.
+
+## Appendix: seed catalogue (from §3's 7 days)
+
+**Hard tier candidates:** `/.env`, `/.env.local`, `/.env.production`,
+`/.env.backup`, `/.env.old`, `/.env.development`, `/.env.bak`, `/.env.prod`,
+`/.env.save`, `/<dir>/.env` (backend, app, api, admin, config, core, src,
+laravel, server, public, …), `/.git/config`, `/.git/HEAD`, `/.git-credentials`,
+`/.aws/credentials`, `/.aws/config`, `/.docker/config.json`, `/credentials.json`,
+`/gcp-credentials.json`, `/firebase-adminsdk.json`, `/@fs/.env`,
+`/@fs/proc/self/environ`, `/static/..%2f.env`, `/db.sql`, `*.php.save`.
+
+**Probe tier candidates by group:**
+
+- **WordPress:** `/wp-login.php`, `/xmlrpc.php`, `/wp-links-opml.php`,
+ `/wp-admin/`, `/wp-json/` (also under `/site`, `/news`, `/cms`, `/blog`, `/wp`,
+ `/wordpress`, `/press`), `/wordpress/`,
+ `/wp-content/plugins/*/wp_filemanager.php`. Not `/wp-content/uploads/` images.
+- **PHP:** any `*.php` final segment on an app not marked PHP; `/_profiler/phpinfo`.
+- **PHP frameworks:** `/vendor/phpunit/`, `/_ignition/`, `/telescope`,
+ `/_debugbar`, `/vendor/composer/installed.json`, ThinkPHP `?s=/index/\think…`.
+- **phpMyAdmin:** `/phpmyadmin/`, `/pma/` (as whole leading segments only).
+- **Java/Spring:** `/actuator`, `/actuator/*`, `*.action`, `*.do`, `*.jsp`.
+- **CGI, routers, IoT:** `/cgi-bin/`, `/setup.cgi`, `/HNAP1`, `/GponForm/`.
+- **Server status and metadata:** `/server-status`, `/server-info`,
+ `/latest/meta-data/`.
+- **Dev and config files:** `/docker-compose.yml`, `/Dockerfile`,
+ `/.gitlab-ci.yml`.
+- **Admin panels (burst limit only, §5.5):** `/admin`, `/admin/login`,
+ `/admin/settings`, `/admin/dashboard`, `/administrator/`.