SECURITY
_Status: being written in the current phase. See REWORK-STATE.md §3 for the authoritative progress list; this file is filled in as the corresponding stream lands._
CSRF protection and its exemptions
Every unsafe-method (POST/PUT/PATCH/DELETE) web request must carry the synchroniser token (X-CSRF-Token header or _token field, HMAC-SHA256(sessionId|APP_ENV, APP_KEY)); the Kernel enforces it BEFORE the controller runs (src/Security/Csrf.php, Kernel::enforceCsrf).
Two exemption surfaces exist, and both require the endpoint to authenticate differently:
CSRF_EXEMPT_PATHS(env knob, prefix-based) — the default
/api/,/tracker/,/theme/,/lang/,/embed/,/mcp. These zones authenticate with bearer tokens / API tokens or are stateless preference switches. The knob is deployment-overridable (real env > .env > defaults, ADR-045) — treat widening it as a security review item. Per-endpoint rationale for /mcp (token-only auth, no ambient credential, logged-in sessions grant nothing; frozen by McpCsrfPostureTest): docs/MCP.md §CSRF posture. An override of the knob MUST keep the /mcp and /api/ prefixes or agent POSTs hit the 403 wall.
Csrf::SIGNATURE_EXEMPT_PATHS(frozen code constant, exact paths,
ADR-058.3) — machine endpoints whose authentication IS a request signature verified by the handler before any state change. Currently exactly one path: /billing/webhook (Stripe S3 — `Stripe-Signature: t=…,v1=…, HMAC-SHA256 over t.<raw body> with STRIPE_WEBHOOK_SECRET`, ±300 s replay window, hash_equals; forged/stale/tampered ⇒ 403 with zero state changes). Deliberately NOT part of the env knob: the webhook exemption must hold unconditionally, and a blanket /billing/ prefix would silently exempt every future billing form POST. Extend only with an ADR + a handler that verifies the signature first.
Both surfaces are frozen by tests (BillingWebhookTest): the webhook path is exempt, no other /billing/ path is, and /pricing/upgrade keeps Kernel CSRF.
External redirects (Stripe-hosted flows)
BaseController::redirect() sanitises every external target to / (open-redirect protection) and must never be weakened for billing. The two flows that LEAVE the site by design — the hosted checkout (S2, POST /pricing/upgrade) and the hosted billing portal (S4, POST /{dashboard,account}/billing/portal) — validate the Stripe-returned URL through ONE shared allowlist, Iya\Security\StripeUrlAllowlist (ADR-058.2/058.4, trap #91): https on a stripe.com host, or the exact authority of STRIPE_API_BASE (the drill-sink arm). Anything else — including a compromised upstream response — degrades to an error flash and an internal redirect, never to the returned URL. Redirects happen via Response::redirect() only after the allowlist match; both flows are frozen by tests (PricingUpgradeTest, BillingEnforcementTest).
Deployment posture (s73 — written after the store was found publicly served)
The CSRF analysis above is unchanged and authoritative. Everything below is the deployment-level posture that
came out of the s73 incident: the store directory was served publicly by nginx and carried a live gateway token in
twelve files. Rotation, containment and the procedures below were all exercised during that round.
**Never put a credential into a file that lives under a web-served directory, a prompt, a note, an artefact or a report.** The store (qwen_access/) was described as a "sanitized export" and was not: it accumulated gateway tokens, session handoffs and scripts with hard-coded tokens. A comment claiming something is sanitized is not a control.
2. Layers, from the outside in
| Layer | Mechanism | Where |
|---|---|---|
| Edge | nginx denies the whole store directory; only qwen-gateway.php is reachable | location ^~ /qwen_access/ { deny all; } |
| API auth | shared token, constant-time compare, accepted as JSON field / X-Qwen-Token / Bearer / ?tk=; optional HMAC ts+sig (±300 s) so signed requests survive token leaks in URL logs | public/qwen-gateway.php |
| Token storage | the live token is environment in the ISPmanager per-site PHP-FPM pool — not in the repo, not in .env, not in nginx | /opt/php85/etc/php-fpm.d/site.d/abuseipdb.ru.conf → env[QWEN_TOKEN] |
| Admin | IP allowlist (ADMIN_IP_ALLOWLIST + admin_ip_allow rows) and a break-glass link | BaseController::requireAdmin(), AdminIpAllowlist |
| Agent channel | GET /__agent/<key> hands a fresh dev window the gateway token, the agent token, the board base and the live head in one request | AgentBootstrapController, key in AGENT_BOOTSTRAP_KEY |
| Admin 2FA | ADMIN_REQUIRE_2FA — currently false on prod (see §6) | .env |
| Secrets at rest | .env 600 iya:iya; the DB and var/ are 403 over HTTP; the SQLite DB is not reachable | docroot permissions |
| Worker | a watchdog outside the worker notices a stale var/logs/worker.log | /etc/cron.d/iya-worker-watchdog |
| Store deny | the same watchdog probes /qwen_access/ anonymously every 10 minutes and shouts if anything answers 200 | /root/iya-deploy/worker-watchdog.sh, check 2 |
| Disk | free space on / is checked every tick against DF_LIMIT_GB (default 10) — a full disk breaks deploys, store writes and backups | same script, check 3 |
| Admin guards | the allowlist must stay non-empty and the break-glass key at least 16 characters, or /admin silently becomes reachable from anywhere | same script, check 4 |
The store deny has a watchdog too
/qwen_access/ is closed by nginx, and that single line is what keeps the incident from repeating — so it is watched. Every 10 minutes the watchdog probes three store paths anonymously; all three must answer 403 (or 404). If any answers 200, it writes a store note (at most hourly) telling the operator that the deny is gone, with the exact nginx snippet to restore it. The check has a testing seam (WATCHDOG_TEST_URL) so the "200 means alarm" path can be proven against a known-open control instead of being taken on faith — it was proven that way before this document was updated.
What the watchdog covers, and why each check exists
Four checks, each with its own hourly alert budget, so one cannot silence another:
- Worker —
var/logs/worker.logolder thanWORKER_STALE_MINUTESmeans visits, rollups, retention, outbox,
geo and the whole contrib chain are stalled. An 8-day outage once went unnoticed this way.
- Store deny — three
/qwen_access/paths are probed anonymously and must answer 403/404. A 200 means the
nginx deny all is gone (ISPmanager update or a hand edit) and the incident can repeat. The check has a WATCHDOG_TEST_URL seam so the alarm path is proven against a known-open control instead of trusted.
- Disk —
/carries the deployment, the store and the deploy backups; the guard shouts belowDF_LIMIT_GB. - Admin guards — the allowlist and the break-glass key are
.envvalues, so they can be emptied by an edit or
an ISPmanager rewrite with no visible symptom. The check reads the LIVE config every tick.
Each was proven by forcing the failing condition (an empty .env copy for check 4, DF_LIMIT_GB=100000 for check 3, WATCHDOG_TEST_URL for check 2), and in every case the alarm fired, a second firing inside the hour was suppressed, and the reset returned it to silence. Check 4's own test found a real bug in it — the count used grep -c . || echo 0, which prints two lines on an empty list and would have broken the guard in precisely the case it exists for.
Recovery assets
staging/reports/RECOVERY-STORE-ONLY.md— the verified procedure to rebuild the tree from the store alone
(964/964 manifest entries, executed in this environment).
/root/iya-deploy/survival/iya-survival-<head>-<ts>.tar.gz— 25.5 MB: the code slot, its manifest, the git
history bundle, the state, the index, the bootstrap, the kit, the note stream, the ops scripts and this procedure. Rebuilt nightly at 04:10, newest three kept. Off-host copy pending the owner's decision — it currently lives on the same disk as the deployment, so it protects against a bad deploy or a deleted file, not against the disk dying.
2FA: why the switch is still off, and the exact order to turn it on
ADMIN_REQUIRE_2FA is enforced inside BaseController::requireAdmin(), next to the IP allowlist — and the break-glass link does not bypass it, because that test lives in the same method. The consequence is a hard lockout risk: turning the switch on while no privileged account holds a confirmed TOTP secret closes /admin for everyone, and recovery means editing the database.
At the time of writing: 2 privileged accounts, 0 with 2FA, so the switch stays false. That is not a security regression — the IP allowlist (37 entries) is the primary control and the panel is unreachable from the open internet. The order to enable it safely:
- Enrol TOTP at
/dashboard/settingsfor one admin (two_factor_enabled=1and a non-empty secret, confirmed). - Sign out, sign in again with the code — prove that enrollment works before flipping anything global.
- Only then set
ADMIN_REQUIRE_2FA=truein.envand reload php-fpm. - Keep the break-glass key: it bypasses the address list, not 2FA (by design — a stolen URL must not become a full
bypass of the second factor).
The watchdog now watches this exact hazard (check 5): every tick it records 2fa require=… privileged=N/M and shouts if the switch is on while zero privileged accounts can satisfy it — so the dangerous combination cannot be entered silently.
The agent bootstrap link: one URL instead of a pasted secret
A dev window starts in a fresh sandbox with no secrets, and everything it needs sits behind secrets: the store needs the gateway token, the board needs the agent token, and the agent token itself is stored in the store. That circle is why every window used to stall with 401 until the owner pasted a 48-character token by hand — and why one window died with six finished artifacts when the token rotated mid-flight.
GET /__agent/<key> breaks the circle. It answers with the starting kit: the gateway URL and token, the agent token, the board base, the live head and its manifest name, plus the four rules that matter (ping first, never publish these values, the store is closed to anonymous access, this URL is a secret). The dev window needs nothing but the URL.
Treat the URL as a bearer secret, because that is what it is:
- the key lives only in
AGENT_BOOTSTRAP_KEY(and inConfig::defaults(), or the env var would be ignored — B-005); - it must be at least 24 characters, otherwise the route stays inert rather than weakly protected;
- comparison is
hash_equals; 10 attempts per hour per address; every failure answers 404, so the path never
confirms its own existence;
- the answer is
Cache-Control: no-storeandX-Robots-Tag: noindex, androbots.txtdisallows/__agent/; - every attempt, granted or denied, is written to
audit_log; - revoking it does not disturb the windows' tokens: change the key in
.env,systemctl reload php-fpm85-iya, and
the old URL dies while the tokens keep working.
The residual risk is stated plainly: whoever holds the URL holds both tokens until it is revoked, and the URL itself appears in the nginx access log (inside the closed store, which answers 403 to the internet). That is accepted for now because the alternative — the owner pasting a long secret into every new window — failed twice in one day. The next step, if it is ever needed, is short-lived one-time token issuance rather than a static URL.
3. The admin allowlist
ADMIN_IP_ALLOWLIST (env) is merged with rows in admin_ip_allow by Iya\Security\AdminIpAllowlist, and requireAdmin() asks that one class — so the gate and the escape hatch cannot disagree. An empty list means "no IP restriction"; a non-empty list means every admin page requires an allowed source address.
Notes that matter operationally:
- the check uses
$_SERVER['REMOTE_ADDR']. **If a proxy/CDN is ever placed in front, every request will look like
it comes from the proxy** and the allowlist becomes either useless or a total lockout. Handle it in the proxy layer, or extend ClientIpResolver, before enabling one.
- the dev windows' sandbox addresses rotate. A window that suddenly gets
403 admin_ip_not_allowedneeds its
/24 added: /root/iya-deploy/allowlist.sh add 1.2.3.0/24.
ADMIN_IP_ALLOWLISTis a.envvalue, so changing it needs no deploy and no restart.
4. The break-glass link
GET /__allow/<key> adds the caller's own address as a single /32 (or /128) into admin_ip_allow.
- the key lives only in
ADMIN_ALLOWLIST_KEY(prod.env, 600) and/root/iya-deploy/.magic_key; - ≥ 16 characters or the route answers 404 — a short key on a public URL is a guessed key;
hash_equals, 10 attempts per hour per address,ADMIN_ALLOWLIST_MAGIC_MAX(20) caps how many rows may ever be
added, and every attempt (accepted or refused) goes to the audit log;
- a wrong key, a disabled feature and an unknown path all answer the same 404, so the endpoint never confirms
its own existence; robots.txt disallows the path and the response carries X-Robots-Tag: noindex + Cache-Control: no-store;
- it exists because a
.envallowlist can lock the owner out of the panel — the fix must not require the panel.
5. Rotating the gateway token
# 1) backup and replace the value in the FPM pool (the file that actually decides)
cp -p /opt/php85/etc/php-fpm.d/site.d/abuseipdb.ru.conf /root/iya-deploy/phpfpm-iyaru-site.conf.bak-$(date +%s)
python3 - <<'PY'
import re, secrets
p = '/opt/php85/etc/php-fpm.d/site.d/abuseipdb.ru.conf'
s = open(p).read()
new = secrets.token_hex(24)
s = re.sub(r'(env\[QWEN_TOKEN\]\s*=\s*)"[^"]*"', lambda m: m.group(1) + '"' + new + '"', s, count=1)
open(p, 'w').write(s)
print(new) # print ONCE, hand it to the owner, never store it in the repo or the store
PY
# 2) graceful reload, then verify BOTH directions
systemctl reload php-fpm85
curl -s -o /dev/null -w 'old:%{http_code}\n' -X POST -H 'Content-Type: application/json' \
-d '{"op":"ping","token":"<old>"}' https://abuseipdb.ru/qwen-gateway.php # must be 401
curl -s -o /dev/null -w 'new:%{http_code}\n' -X POST -H 'Content-Type: application/json' \
-d '{"op":"ping","token":"<new>"}' https://abuseipdb.ru/qwen-gateway.php # must be 200
Do it at a window boundary: a window reads its token from its own kit copy, so rotating mid-task cuts it off with 401. The old value in public/qwen-gateway.php (QWEN_GATEWAY_TOKEN) is only the repo default and must never be used in production.
6. What is deliberately NOT done (open items, owner decision)
| Item | State | Why it is open |
|---|---|---|
ADMIN_REQUIRE_2FA | false on prod | Enabling it locks out any admin who has not enrolled TOTP yet; do it after enrolment, or together with ADMIN_IP_ALLOWLIST (already on) |
| Agent-token rotation | not done | It was never public (it lived in _private/, 403) and rotating it would disconnect every window for no gain |
QWEN_ALLOW_CIDR | unset | The gateway accepts the token from anywhere; an IP allowlist for the gateway is available in the file but would break roaming clients |
| Store history | contains old artefacts | Files scrubbed of tokens; the history of how they got there is in _audit/notes.ndjson (the two incidents are documented there) |
7. If you find a leaked credential
- Contain: close the exposure (nginx/location/permissions) before anything else.
- Rotate: §5 for the gateway token;
php bin/iyaor the admin UI for anything else. - Scrub: replace the value in every readable file (
grep -rIl '<prefix>'), verify the sweep is empty, and keep
a backup of what you removed.
- Record: a note in
_audit/notes.ndjsonwith what leaked, where, for how long (if known), and what rotation
changed. Never write the secret itself into that note.
- Verify the gate still works: the site,
/status,/admin, and the gateway with the new token.
8. How recovery is verified (three legs, run on the same head)
The claim "we can recover" is worth exactly as much as the last time somebody ran it. This installation is verified three different ways, all against the same head, and each way produces numbers that a stranger can reproduce.
Leg 1 — the survival kit. iya-survival-<head>-<ts>.tar.gz (~28 MB) carries the code slot, its manifest, the history bundle, the state, the index, the bootstrap, the kit, the note stream, the ops scripts and the recovery procedure. The check is not "it extracts": the archive's own MANIFEST.json is verified file by file, and the code slot inside it is verified against the head manifest it claims (_FILES-<head>.txt). On 2026-11-02, head 102b484: 37/37 files, code slot 984/984, zero failures. A copy also lives on the operator workstation (a different host and disk) with the same sha256 verified there.
Leg 2 — the guards. Seven checks, each proven by forcing its failure condition rather than by reading the code: worker freshness, the store deny, free disk, the admin allowlist, the 2FA lockout, ops-script drift and board starvation. Each forced run must produce exactly one alert, suppress a second firing inside the hour, and return to silence after the reset. The second acceptance run on a later head grew every counter by exactly one, which is what "still works" looks like.
Leg 3 — the store alone. RECOVERY-STORE-ONLY.md executed for real: the slot from workspace/, extracted into a clean directory, verified against workspace/_FILES-<head>.txt. On 2026-11-02, head 102b484: 9.5 MB slot, 0.21 s extract, 984 files, 984/984 OK. The recovered tree carries everything a recovery needs (entry points, config, all 28 migrations, the security docs).
What these legs do NOT prove
- they do not prove that data comes back: the database is never in the kit and never in the store, and a code recovery
does not roll data back — that is deliberate;
- they do not prove schema rollback: migrations are forward-only, so an older head runs against the newer schema (safe
only because every migration so far is additive);
- they do not prove the off-host copy is alive — that is a separate sha256 check on the workstation, and the workstation
is not fireproof;
- they are not a substitute for the full suite on the live head, which is a separate gate.
A protocol that passed once is an anecdote. The numbers above exist so the next person can run the same three legs and know whether the answer is still "yes".
Версия 2.2.0 · AbuseIPDB.RU · webmaster@abuseipdb.ru · source: docs/SECURITY.md