Sfoglia il codice sorgente

Security audit: OWASP-style findings for Astralis, Statum and Spry

Read-only audit of the web stack against an OWASP Top 10 checklist
adapted to this codebase. 18 findings plus a recorded non-findings
section, each with severity, OWASP category, file:line evidence,
reasoning and a proposed fix direction.

Highlights:
- SA-1 (high): logout does not revoke the signed auth frame; the frame
  can be replayed to re-authenticate and even re-cache the slot via
  /_statum/slots.
- SA-2 (high): FilesystemResource traversal guard is a has_prefix check
  whose canonicaliser does not resolve symlinks; also documents the
  EndpointRouter regression (9a3bb18b) that builds the '**' remainder
  from the route instead of the request, currently masking traversal.
- SA-3 (high): no request-size limits anywhere; ServerInput buffers
  unbounded bodies in memory and MHD is started with no
  CONNECTION_LIMIT/MEMORY_LIMIT options.
- Mediums: missing invalid_after enforcement on restore_slot, opt-in
  auth guards, unauthenticated entrypoint/SSE, weak admin seeding
  example, password policy gaps (incl. no max length - Argon2 DoS),
  web-config.json secret handling, signer claim unverified, account
  enumeration on register.
- Lows: genkeys secrets to stdout, missing security headers, bearer
  slot model, SSE resource exhaustion, no Argon2 rehash, e.message in
  user notifications, new Function() expression primitive.
- SA-19 records what was checked and found sound (SQL parameterisation,
  markdown/markup escaping, X25519 sealing, CLI argv spawns) so the
  next audit does not re-derive it.

No code was changed; this is the Phase 1 deliverable for goal/23.
saddle-Clanker-Saddle 4 settimane fa
parent
commit
ae658a3dda
1 ha cambiato i file con 627 aggiunte e 0 eliminazioni
  1. 627 0
      docs/security-audit.md

+ 627 - 0
docs/security-audit.md

@@ -0,0 +1,627 @@
+# Security audit — Astralis, Statum, Spry
+
+OWASP Top 10-style review of the Vala/GObject web stack, adapted to this
+codebase. Audit of `goal/security` @ `eca07a0` (Astralis), `c741172` (Statum),
+`b6aad2e` (Spry). Read-only audit: no code was changed; every finding below
+was verified against source, and severity/confidence are stated per finding.
+
+Severity: **high** = exploitable by an unauthenticated or low-privilege
+attacker with real impact; **medium** = exploitable with preconditions, or
+degrades a security control; **low** = defence-in-depth / hygiene.
+Confidence: **confirmed** = reproduced by code reading with the full call
+chain traced; **suspected** = strong evidence, one step unverified (the step
+is named).
+
+## Summary (ordered by severity)
+
+- SA-1 **high** — Logout does not revoke the session: the signed `auth` frame
+  stays valid and re-authenticates the user (Spry/Statum).
+- SA-2 **high** — `FilesystemResource` traversal guard is a prefix check with
+  a non-symlink-resolving canonicaliser (Astralis).
+- SA-3 **high** — No request-size limits anywhere: unbounded in-memory body
+  buffering (Astralis/Statum).
+- SA-4 **medium** — Slot frames never expire server-side: signed snapshots
+  can be replayed forever (`invalid_after` unenforced on the write path).
+- SA-5 **medium** — Disabled/deleted accounts keep working: only the Spry
+  guards re-validate; `require_login`/`require_permission` are opt-in per
+  action, and nothing else checks (Statum endpoints unauthenticated).
+- SA-6 **medium** — `EntrypointEndpoint` and the SSE channel are callable
+  unauthenticated (info disclosure / DoS surface).
+- SA-7 **medium** — Admin seeding defaults to weak, well-known credentials
+  (`change-me` documented as the example; no forced rotation).
+- SA-8 **medium** — No password policy beyond length ≥ 6; no upper bound on
+  password length (Argon2 memory/CPU DoS with multi-megabyte passwords).
+- SA-9 **medium** — `web-config.json` carries all long-lived secrets
+  (signing + sealing keys) with no permission check or warning; ephemeral-key
+  fallback silently runs with no persistent identity.
+- SA-10 **medium** — `SigningProvider.verify_frame` ignores the frame's
+  `signer` claim (weakens multi-signer deployments; fine for single-signer).
+- SA-11 **medium** — Account enumeration via registration responses
+  ("username taken" vs "email taken") and via timing on the login path is
+  partially mitigated only.
+- SA-12 **low** — `statum-genkeys` prints secret keys to stdout.
+- SA-13 **low** — No standard security response headers (CSP, HSTS,
+  X-Content-Type-Options, X-Frame-Options); no CORS story at all.
+- SA-14 **low** — `X-Statum-Slot` header values are trusted as the client's
+  held-state claim with no binding to a session (slot spoofing within the
+  signature constraint).
+- SA-15 **low** — SSE/channel endpoints keep unbounded per-connection state;
+  no connection cap, no idle timeout.
+- SA-16 **low** — Password hashes are stored with fixed Argon2 parameters and
+  never rehashed on parameter change.
+- SA-17 **low** — Internal error messages surfaced to users in notifications
+  (`e.message` printf'd into notify text).
+- SA-18 **low** — `new Function('with(scope){...}')` in `statum.js` compiles
+  attribute expressions — a page-authored template injection primitive, safe
+  only while page markup is fully trusted.
+- SA-19 **info** — Non-findings recorded (SQL injection, markdown XSS,
+  markup XSS, dummy-hash timing) — checked and found sound, listed so the
+  next audit does not re-derive them.
+
+---
+
+## SA-1 — Logout does not revoke the session (high)
+
+**OWASP:** A07 Identification and Authentication Failures
+**Repo:** Spry + Statum
+**Confidence:** confirmed (full chain traced; one browser-behaviour step noted)
+
+`LogoutAction` (Spry/src/Actions/LogoutAction.vala:18-27) calls
+`state_service.clear_slot(auth.key)`, which removes the slot from the
+server-side in-memory dictionary (`StateService.clear_slot`,
+Statum/src/StateService.vala:97-100). That is the *entire* revocation. The
+client is told to drop its copy via a `clear` directive — but the signed
+frame the client held is still cryptographically valid:
+
+1. The frame's signature (`SigningProvider.author_frame`,
+   Statum/src/Cryptography/SigningProvider.vala:84-90) covers only the
+   snapshot content. Nothing in the signature binds the slot to "not logged
+   out" — there is no revocation list, no key rotation, no server-side state
+   that marks the frame dead.
+2. `statum.js` persists frames in `localStorage`/`sessionStorage`
+   (`persistEntry`, Statum/js/statum.js:330-341). A user (or malware) who
+   saved the frame before logout can restore it.
+3. On the next request the client sends the frame via `X-Statum-Slot`;
+   `HeldSlotResolver.resolve_frame`
+   (Statum/src/HeldSlotResolver.vala:173-215) verifies the signature and —
+   crucially — `SpryAuth.current` (Spry/src/Auth.vala:114-137) maps the
+   frame straight to a `CurrentUser` with `user_id` from the decrypted
+   private half.
+4. Worse: if the client first POSTs the frame to `/_statum/slots`,
+   `SlotPostEndpoint` (Statum/src/SlotPostEndpoint.vala:39-59) re-verifies
+   the signature and `restore_slot` **re-caches the slot server-side**
+   (`StateService.restore_slot`, Statum/src/StateService.vala:172-197), so
+   even the reference-form header (which resolves via the server cache)
+   starts working again. The logout is fully undone by replaying the frame.
+
+**Impact:** "log out" on a shared machine does not end the session; anyone
+with a copy of the frame (localStorage, a proxy log, a backup) re-enters as
+that user until the signing key changes.
+
+**Fix direction:** add a server-side validity epoch: include a monotonically
+increasing `user`-scoped version (or a per-slot generation counter) in the
+signed snapshot, and have `revalidate`/`restore_slot` compare it against a
+`users` row (e.g. `sessions_valid_after` timestamp bumped on logout and on
+password change). Alternatively rotate the slot key on logout and reject
+frames whose snapshot predates it. `restore_slot` must also refuse frames
+older than `invalid_after` (see SA-4).
+
+---
+
+## SA-2 — FilesystemResource traversal guard: prefix check, no symlink resolution (high)
+
+**OWASP:** A01 Broken Access Control (path traversal)
+**Repo:** Astralis/src/Endpoints/FilesystemResource.vala:54-60, 208-213
+**Confidence:** confirmed (guard flaw confirmed by reading; exploitability is
+currently masked by an unrelated routing bug — SA-2b)
+
+```vala
+string resolved_path = canonicalize_path(full_path);
+string resolved_server_path = canonicalize_path(server_path);
+if (!resolved_path.has_prefix(resolved_server_path)) { ... 403 ... }
+```
+
+Two independent defects:
+
+1. **Prefix, not containment.** `has_prefix` accepts a sibling directory:
+   serving from `/srv/www`, a resolved path of `/srv/www-evil/secret` passes
+   the check. Reachability requires a `..` payload to survive routing and
+   resolve outside the tree (see masking note below), or a symlink planted
+   inside the tree pointing outside — `File.get_path()` does **not** resolve
+   symlinks (unlike `realpath`/`File.resolve_symbolic_link`), so the
+   canonicaliser only lexically normalises.
+2. **No boundary separator.** Even a correct absolute prefix needs the next
+   character to be `/` (or an exact match) — the classic
+   `/srv/www` vs `/srv/www2` confusion.
+
+**SA-2b — masking bug (functional, security-relevant).** While tracing
+reachability I found that `RouteContext` builds the `**` remainder from the
+*route's* segments instead of the request's
+(Astralis/src/Components/EndpointRouter.vala:143-147 — regression introduced
+in 9a3bb18b, which replaced `path_components[i]` with
+`matched_route.route_segments[i]`). For a `/assets/**` route the mapped `**`
+parameter is therefore always the literal string `"**"`, and
+`FilesystemResource.get_relative_path` returns it — deep routes serve
+`server_path/**` (a file literally named `**`) rather than the requested
+subpath. This *accidentally* blocks `../` payloads today because the
+attacker-controlled remainder never reaches `Path.build_filename`. The
+traversal guard is nonetheless wrong and becomes exploitable the moment the
+routing bug is fixed (or on the SHALLOW/EXACT match paths, which use
+`route_components`/named params). Fix the guard and the router bug together,
+with a traversal regression test (`/assets/../../etc/passwd`, URL-encoded
+`%2e%2e%2f`, symlink escape).
+
+**Fix direction:** canonicalise with
+`File.resolve_symbolic_link_async` (or `realpath`), then require
+`resolved_path == resolved_server_path ||
+resolved_path.has_prefix(resolved_server_path + "/")`. Reject any request
+whose pre-canonicalisation relative path contains `..` outright.
+
+---
+
+## SA-3 — No request-size limits; unbounded in-memory buffering (high)
+
+**OWASP:** A05 Security Misconfiguration / DoS
+**Repo:** Astralis/src/Server/ServerInput.vala:47-51 (buffering),
+Astralis/src/Server/Server.vala:89-95 (no cap),
+Astralis/src/Data/FormDataParser.vala:122-176 (whole-body reads),
+Statum/src/SlotPostEndpoint.vala:27 (`read_all`),
+Statum/src/ChannelSubscriptionEndpoint.vala:30 (`read_all`)
+**Confidence:** confirmed
+
+- `ServerInput.write` appends every upload chunk to an in-memory `Series`
+  with no maximum. `read_all()` then composes them into one contiguous
+  buffer. A single `Content-Length: 4294967296` POST is fully buffered.
+- `FormDataParser.parse_multipart` reads the entire body, then copies parts
+  (`body_data`, `part_data`, `file_data`) — roughly 3× the body size in
+  peak memory, plus an O(n·m) `find_bytes` boundary scan
+  (FormDataParser.vala:234-252) over the whole body per part.
+- The MHD daemon is started with only `ALLOW_SUSPEND_RESUME | USE_TURBO |
+  USE_EPOLL` (Server.vala:111-122): no `MHD_OPTION_CONNECTION_LIMIT`, no
+  `MHD_OPTION_CONNECTION_MEMORY_LIMIT`, no per-connection upload cap, no
+  request timeout.
+- `SlotPostEndpoint` and `ChannelSubscriptionEndpoint` parse arbitrary JSON
+  bodies of arbitrary size (frame arrays unbounded).
+
+**Impact:** trivial remote memory exhaustion — a handful of parallel large
+POSTs OOMs the process; slow-loric attacks also hold buffered memory for the
+connection lifetime.
+
+**Fix direction:** pass `MHD_OPTION_CONNECTION_LIMIT` and
+`MHD_OPTION_PER_IP_CONNECTION_LIMIT` in `Daemon.start`; enforce a configurable
+`max_request_body` (reject >413 immediately, ideally as soon as
+`Content-Length` is known, and defensively while streaming in
+`ServerInput.write`); bound `X-Statum-Slot` header count/size and the
+`/_statum/slots` array length.
+
+---
+
+## SA-4 — Signed frames never expire on the server side (medium)
+
+**OWASP:** A07 Identification and Authentication Failures (replay)
+**Repo:** Statum/src/StateService.vala:172-197 (`restore_slot`),
+Statum/src/SlotPostEndpoint.vala:39-59
+**Confidence:** confirmed
+
+`HeldSlotResolver.resolve_frame` drops frames past `invalid_after`
+(HeldSlotResolver.vala:195-201) — but `SlotPostEndpoint`, the endpoint whose
+whole purpose is to rehydrate the server cache from client frames, verifies
+only the signature and calls `restore_slot`, which **never checks
+`invalid_after`**. The 7-day `EXPIRY_SECONDS` window
+(StateService.vala:19) is only enforced by the *cleanup sweep* on
+`last_touched` — and `restore_slot` sets `last_touched = now`
+(StateService.vala:192), so a replayed frame looks freshly touched.
+
+**Impact:** any frame ever legitimately issued remains usable to re-cache a
+slot indefinitely (compounding SA-1: a logged-out session frame can be
+re-cached forever). Also a slow memory leak: frames replayed on a schedule
+keep their slots pinned.
+
+**Fix direction:** in `restore_slot`, parse `invalid_after` from the
+snapshot and reject (with `invalidate`) frames past it; do not reset
+`last_touched` to `now` on restore — keep the snapshot's `as_at`.
+
+---
+
+## SA-5 — Disabled/deleted accounts keep working; guards are opt-in (medium)
+
+**OWASP:** A01 Broken Access Control
+**Repo:** Spry/src/Auth.vala:103-137 (`current` — snapshot trust),
+Spry/src/Auth.vala:287-302 (`revalidate` — good),
+Statum/src/*.vala (endpoints with no auth notion)
+**Confidence:** confirmed
+
+The design is: `SpryAuth.current()` is a cheap, *non-authoritative* read of
+the signed snapshot; the authoritative checks are `require_login` /
+`require_permission`, which re-validate against the user store
+(`revalidate`, Auth.vala:287-302 — correctly fails closed). Two gaps:
+
+1. Every shipped Spry action that needs auth does call the guards
+   (Login/Register/ChangePassword + all UserManagementActions — verified in
+   each file). But nothing in the framework *forces* a guard: a new action
+   that forgets `require_permission_guard()` is silently public. There is no
+   "default deny" hook in `StatumAction.handle_request`
+   (Statum/src/StatumHandlers.vala:105-149), no route-level auth metadata,
+   and no test/assertion that an action type carries a guard.
+2. `current()`-derived display data (username, permissions) keeps rendering
+   for disabled/deleted accounts until the slot is cleared — fine for nav
+   bars, but any *decision* made on `current()` rather than the guards is a
+   vulnerability. The doc comments say this, but nothing enforces it.
+
+**Fix direction:** add an opt-out (not opt-in) authorisation hook:
+`StatumConfigurator` gains a `default_guard` invoked by
+`StatumAction.handle_request` before `handle()`, or an
+`[RequiresPermission]`-style registration-time declaration that
+`register_actions` can verify reflectively. At minimum, add a lint/test that
+fails when an action type neither calls a guard nor opts out explicitly.
+
+---
+
+## SA-6 — Entrypoint and SSE channel endpoints are unauthenticated (medium)
+
+**OWASP:** A01 Broken Access Control / A05 Misconfiguration
+**Repo:** Statum/src/EntrypointEndpoint.vala:24-56,
+Statum/src/ChannelEndpoint.vala:27-40,
+Statum/src/ChannelSubscriptionEndpoint.vala:22-33
+**Confidence:** confirmed (design intent understood; residual risk stated)
+
+- `GET /_statum/entrypoint?uri=…` resolves held slots and runs the page's
+  entrypoint handler with **no** authentication and **no** check that the
+  caller may see the page. The entrypoint's directives (including `set`
+  frames for the slots it creates) are returned to anyone.
+- `GET /_statum/channel` opens an SSE stream for anyone; the 128-bit random
+  channel id (ChannelEndpoint.vala:100-107, `Sodium.Random` — good entropy)
+  is the *only* authorisation for `PATCH /_statum/channel/{id}`, and
+  subscription to any slot key immediately pushes that slot's current signed
+  frame (ChannelSubscriptionEndpoint.vala:56-66).
+
+**Impact:** depends entirely on what apps put in public slot data: any
+session-agnostic data leak is reachable unauthenticated; SSE also gives an
+uncosted resource to anonymous clients. The framework cannot know what is
+sensitive, so it must let apps gate these endpoints.
+
+**Fix direction:** provide guard hooks on both endpoints (e.g. an
+overridable `authorize(http_context)` returning a 401/redirect result),
+documented as "put your auth guard here"; consider binding channel ids to
+the held auth slot so a channel leak cannot be used cross-session.
+
+---
+
+## SA-7 — Admin seeding defaults to weak documented credentials (medium)
+
+**OWASP:** A07 Identification and Authentication Failures
+**Repo:** Spry/src/Auth.vala:234-256 (`ensure_admin`),
+Spry/tools/spry/templates/app-main.vala:56-72,
+Spry/tools/spry/templates/README.md:43-48
+**Confidence:** confirmed (pattern; not a hardcoded backdoor)
+
+`spry new` scaffolds `seed_admin()` reading `SPRY_SEED_ADMIN_USERNAME` /
+`SPRY_SEED_ADMIN_PASSWORD`; the README's example is
+`export SPRY_SEED_ADMIN_PASSWORD=change-me`, and the docstring of
+`ensure_admin` itself uses `"change-me"`. Nothing forces a rotation, warns
+at runtime when the seeded password matches known-weak values, or expires
+seeded admin accounts. `ensure_admin` is idempotent and will not even reset
+a password — good — but the shipped example invites deploying `change-me`.
+
+**Fix direction:** scaffold prints a loud warning when seeding with a
+password shorter than N or matching a deny-list (`change-me`, `admin`,
+`password`); better, generate a random password, print it once, and require
+a change on first login (needs a `must_change_password` flag — see SA-8).
+
+---
+
+## SA-8 — Password policy: min length 6, no max, no complexity, no lockout (medium)
+
+**OWASP:** A07 Identification and Authentication Failures
+**Repo:** Spry/src/Actions/RegisterAction.vala:73-78,
+Spry/src/Actions/ChangePasswordAction.vala:55-60,
+Spry/src/Actions/AlterUserAction.vala (same 6-char check),
+Spry/src/Authentication/UserService.vala:44-56
+**Confidence:** confirmed
+
+- Minimum length is 6 with no complexity guidance, no breach-list check, no
+  rate limiting and no lockout on `LoginAction` — online brute force is
+  unthrottled (only Argon2's cost slows the attacker, and it slows the
+  defender equally).
+- **No maximum length.** Argon2 hashes the entire password;
+  `Sodium.PasswordHashing.hash` (Spry/vapi/libsodium.vapi:267-274) has no
+  length cap. A 32 MB password burns ~256 MiB and seconds of CPU *per
+  attempt* on the server — an amplified DoS from a single register/login
+  request (compounding SA-3's unbounded body). OWASP recommends capping at
+  ~64-128 characters.
+
+**Fix direction:** cap password length at 128 (reject earlier with 400);
+raise the minimum to 8+ with a weak-password deny-list; add per-account +
+per-IP throttling on login (even a simple in-memory window); document the
+choice not to lock accounts (lockout enables its own DoS).
+
+---
+
+## SA-9 — web-config.json secret handling (medium)
+
+**OWASP:** A02 Cryptographic Failures / A05 Misconfiguration
+**Repo:** Statum/src/Statum.vala:77-120,
+Astralis/src/Core/WebConfigLoader.vala:31-76,
+Spry/tools/spry/Keys.vala (writes 0600 — good)
+**Confidence:** confirmed
+
+All six long-lived key fields (Ed25519 signing, Ed25519 encryption-signing,
+X25519 sealing — secret and public halves) live in plaintext in
+`web-config.json`, discovered from the CWD or `ASTRALIS_CONFIG_PATH`. The
+loader never checks file permissions — a world-readable config silently
+yields every session-signing key. `spry keys` does chmod 0600
+(Keys.vala:109ff, Generator.write_file mode parameter), but hand-edited or
+copied configs are never re-checked, and a manual `statum-genkeys` redirect
+leaves whatever umask produced. The fallback when keys are absent is to run
+with **ephemeral keys** (Statum.vala:86-94 warns loudly — good) which
+silently invalidates all client-held sessions on restart but keeps serving;
+an operator who misses the warning runs a server whose sessions die daily.
+
+**Fix direction:** at startup, stat the resolved config files; refuse (or
+require an explicit override) when the statum section is present and the
+file is group/world-readable. Consider env-var-only secret injection
+(`STATUM_SIGNING_SECRET_KEY` etc.) for container deployments where the
+config file is image-baked.
+
+---
+
+## SA-10 — verify_frame ignores the signer claim (medium)
+
+**OWASP:** A02 Cryptographic Failures
+**Repo:** Statum/src/Cryptography/SigningProvider.vala:99-103
+**Confidence:** confirmed; impact is deployment-dependent (low today,
+single-signer)
+
+`verify_frame` verifies `frame.content`/`frame.signature` against *this
+provider's* key regardless of `frame.signer`. In a deployment with more than
+one signing instance (load balancing, blue/green, key rotation windows) a
+frame signed by key A is accepted while claiming `signer: B`. Nothing today
+exploits the mismatch — the signer string is informational — but the API
+invites trust in a field it does not validate, and future multi-key support
+would silently build on it.
+
+**Fix direction:** `verify_frame` should decode `frame.signer` (it is
+`Base64(public_key)`) and use `verify_with_key` with it, falling back to the
+local key only when the signer is unknown — or simply assert
+`frame.signer == signer_id` and document the single-signer contract.
+
+---
+
+## SA-11 — Account enumeration surfaces (medium)
+
+**OWASP:** A07 Identification and Authentication Failures
+**Repo:** Spry/src/Actions/LoginAction.vala:38-45 (good — identical message),
+Spry/src/Actions/RegisterAction.vala:87-96,
+Spry/src/Authentication/UserService.vala:44-56
+**Confidence:** confirmed (enumeration on register; login timing only
+partially equalised)
+
+`LoginAction` correctly answers one generic message for unknown-user and
+wrong-password, and `authenticate_user` burns the dummy hash
+(UserService.vala:47-50) so both paths cost one Argon2 verify — well done.
+Residual surfaces:
+
+1. **Registration enumerates.** "Username already exists" vs "Email already
+   registered" vs success reveals which identities are present on the
+   system. That is a product trade-off (usability), but worth a conscious
+   decision — many sites fold both into one message.
+2. **Disabled accounts answer identically to bad credentials** (good,
+   UserService.vala:51) but the *timing* differs: disabled accounts skip the
+   `Sodium.PasswordHashing.check` on the real hash only when the password
+   check itself passes — i.e. the timing oracle requires already knowing the
+   password. Negligible; noted for completeness.
+3. **`get_user_by_username` timing** on unknown vs known username is
+   equalised only by the dummy hash — correct as written.
+
+**Fix direction:** decide and document the enumeration stance for
+registration; optionally rate-limit register by IP (SA-8).
+
+---
+
+## SA-12 — statum-genkeys prints secret keys to stdout (low)
+
+**OWASP:** A09 Security Logging and Monitoring Failures (secret exposure)
+**Repo:** Statum/tools/statum-genkeys/statum-genkeys.vala:31-40
+**Confidence:** confirmed; deliberate design (ready-to-paste JSON)
+
+The tool prints all three secret keys to stdout for pasting into
+`web-config.json`. Shell history, terminal scrollback, CI logs and
+`2>&1 | tee` captures all acquire the keys. `spry keys` wraps it and writes
+the file at 0600, but direct use (as the Statum warnings instruct) leaks to
+whatever captured the output.
+
+**Fix direction:** add `--out <file>` (write 0600 directly) and print only
+public halves to the terminal; or print secrets only with an explicit
+`--print-secrets` acknowledgement.
+
+---
+
+## SA-13 — No security response headers (low)
+
+**OWASP:** A05 Security Misconfiguration
+**Repo:** Astralis (no header defaults anywhere in src/),
+Statum/src/StatumDirectives.vala:65-66 (sets `Cache-Control: no-store` only)
+**Confidence:** confirmed
+
+Nothing sets `Content-Security-Policy`, `Strict-Transport-Security`,
+`X-Content-Type-Options: nosniff`, `X-Frame-Options`/`frame-ancestors`,
+`Referrer-Policy`. Directive responses do send `Cache-Control: no-store`
+(StatumDirectives.vala:66 — good, keeps frames out of shared caches), and
+SSE sends `no-cache`, but HTML pages and static files carry no defaults.
+Given the client renders server data via `innerHTML` paths (stm-html /
+stm-markdown), a CSP would materially shrink the XSS blast radius.
+
+**Fix direction:** a pipeline component in Astralis that appends a
+configurable default header set (`nosniff`, `frame-ancestors 'self'`,
+`referrer-policy strict-origin-when-cross-origin`, and a CSP template apps
+extend); HSTS only behind explicit opt-in (it bricks local HTTP dev
+otherwise).
+
+---
+
+## SA-14 — Held-slot claims are not bound to a session (low)
+
+**OWASP:** A01 Broken Access Control / A07
+**Repo:** Statum/src/HeldSlotResolver.vala:56-92,
+Statum/js/statum.js:941 (client sends `X-Statum-Slot`)
+**Confidence:** suspected — the one unverified step is whether any shipped
+slot type carries data whose cross-user confusion matters
+
+Any client may present any set of `X-Statum-Slot` headers; frames must
+verify against the server's signing key, but *which* slots a client claims
+to hold is entirely client-chosen and uncorrelated with any session notion.
+Two users on different machines can both present the same valid frame (it is
+bearer data by design — like a cookie, but never bound to anything). This is
+the root of SA-1's severity: the auth slot is a bearer token with no
+revocation and no subject binding beyond `user_id` inside the encrypted
+half.
+
+**Fix direction:** document the bearer-token model explicitly; for the auth
+slot specifically, bind it (inside the encrypted private half, which the
+server controls) to a server-generated session id that logout and password
+change invalidate — this is the same mechanism SA-1 needs, and one
+implementation serves both.
+
+---
+
+## SA-15 — SSE/channel resource exhaustion (low)
+
+**OWASP:** DoS
+**Repo:** Statum/src/ChannelEndpoint.vala:21-40 (Series, per-connection
+subs), Astralis/src/Endpoints/SseEndpoint.vala:174-179 (100 ms poll loop)
+**Confidence:** confirmed
+
+Every SSE connection allocates a `ChannelConnection` with an unbounded
+subscription `Set`, registered forever until disconnect; nothing caps total
+connections or total subscriptions, and the keep-alive loop wakes every
+100 ms per connection (cheap, but N connections = 10·N wakeups/sec on the
+main loop). Combined with SA-3's missing `MHD_OPTION_CONNECTION_LIMIT`,
+an anonymous client can open thousands of channel streams.
+
+**Fix direction:** cap concurrent SSE connections per IP and globally;
+bound subscriptions per channel; lengthen the poll interval or drive close
+notification from the disconnect signal rather than polling `is_closed`.
+
+---
+
+## SA-16 — No Argon2 rehash-on-parameters-change (low)
+
+**OWASP:** A02 Cryptographic Failures
+**Repo:** Spry/vapi/libsodium.vapi:267-281 (no `str_needs_rehash` binding),
+Spry/src/Authentication/UserService.vala:44-56
+**Confidence:** confirmed
+
+Hashes are created with `OPSLIMIT_MODERATE`/`MEMLIMIT_MODERATE` (Argon2id,
+m=256 MiB, t=3 — sound for 2024+ interactive auth). There is no
+`crypto_pwhash_str_needs_rehash` binding and no migration path, so raising
+the cost in future leaves every existing hash at the old parameters until
+the user next changes password.
+
+**Fix direction:** bind `crypto_pwhash_str_needs_rehash`; on successful
+login, if the stored hash needs rehash, rewrite it transparently.
+
+---
+
+## SA-17 — Internal error text reaches users (low)
+
+**OWASP:** A05 Security Misconfiguration (information exposure)
+**Repo:** Spry/src/Actions/*.vala — every `*_failed_message.printf(e.message)`
+(RegisterAction.vala:96, ChangePasswordAction.vala:76,
+AlterUserAction.vala:101, DeleteUserAction.vala:49,
+GrantPermissionAction.vala:52, RevokePermissionAction.vala:53,
+SetUserEnabledAction.vala:46), Astralis/src/Server/Server.vala:215 (logs
+only — fine)
+**Confidence:** confirmed
+
+Storage-layer exception messages (which for SQLite-backed
+Invercargill-Sql can include file paths and constraint SQL fragments) are
+formatted straight into `notify("error", …)` text shown in the browser. No
+passwords/secrets appear (verified: no logging of credentials anywhere in
+Spry/Statum src), but internals leak.
+
+**Fix direction:** log the full `e.message` server-side
+(`warning(...)`), show a generic message client-side.
+
+---
+
+## SA-18 — new Function() template-expression evaluation (low)
+
+**OWASP:** A03 Injection
+**Repo:** Statum/js/statum.js:174-190 (compileExpr), 3362-3374 (attribute
+binding)
+**Confidence:** confirmed as a primitive; not currently exploitable because
+expressions come from page markup only
+
+`stm-*` attribute values are compiled with
+`new Function('scope', 'with(scope){...}')` and evaluated against a Proxy
+scope. Any path where *user data* becomes an attribute expression string
+becomes arbitrary JS in the page origin. Today expressions are authored in
+server-side page templates (trusted developer input), and user data enters
+only as *values* (`stm-text` → `textContent`, `stm-html` → innerHTML,
+`stm-markdown` → the CommonMark renderer), so no injection path exists in
+the shipped stack. This is a sharp edge to document and defend.
+
+**Fix direction:** document "attribute expressions are trusted template
+authoring, never bind them to user data"; consider a small expression
+whitelist parser (paths, comparisons, ternaries) to make misuse impossible.
+
+---
+
+## SA-19 — Checked and found sound (non-findings)
+
+Recorded so future audits don't re-derive them:
+
+- **SQL injection — sound.** All Spry queries go through
+  Invercargill-Sql expression trees; `ExpressionToSqlVisitor.visit_literal`
+  (reference/Invercargill-Sql/src/expressions/expression-to-sql-visitor.vala:146-157)
+  emits `:pN` placeholders and collects values for binding — values never
+  interpolate into SQL text. (Report-only repo; `visit_property` does append
+  identifiers unquoted, but property names come from entity definitions,
+  never user input, in every Spry caller.)
+- **Markdown XSS — sound.** The CommonMark renderer escapes all text/code/
+  html literals (`mdEscape`, statum.js:3145-3148), escapes attributes
+  (`mdEscapeAttr`), allowlists URL schemes (`safeMarkdownUrl`,
+  statum.js:1275-1282) and renders raw HTML inert. Prior audit commit
+  f0c967d fixed the `>` attribute escape.
+- **Server-rendered markup XSS — sound.** libxml2's HTML serializer (used by
+  `MarkupDocument.to_html`) escapes text and attribute content; the one
+  hand-built HTML path (directory listing) escapes via `Markup.escape_text`
+  and URI-escapes link targets (FilesystemResource.vala:152-165).
+- **Markup template engine — sound.** Parsed once with
+  `Html.ParserOption.NONET` (MarkupTemplate.vala:75-80); no runtime
+  string interpolation into markup.
+- **Encryption — sound.** X25519 sealing (libsodium `crypto_box_seal`)
+  generates a fresh ephemeral key/nonce per seal — no nonce-reuse class;
+  Ed25519 detached signatures are constant-time verified in libsodium; the
+  namespace prefix prevents cross-protocol blob confusion
+  (EncryptionProvider.vala:90-95, 133-152).
+- **spry CLI — sound.** All spawns use argv arrays, never a shell
+  (Deploy.vala:169-171, Dev.vala:116-129); scaffolding writes 0644 with
+  web-config.json at 0600 (Generator.vala:29-45).
+- **statum-mkres / statum-mkpstm — sound.** Build-time packers: they read
+  files and emit Vala sources; no archive extraction, so no zip-slip
+  surface.
+- **Login timing — sound.** Identical response text for unknown user vs
+  wrong password, with a dummy-hash Argon2 verify equalising the unknown
+  user path (UserService.vala:47-53).
+
+## Suspected-but-unconfirmed summary
+
+- **SA-14** (slot bearer-model impact) — confirming requires a threat-model
+  decision about which slot types may be shared; code-wise the model is as
+  described.
+- **SA-2 exploitability today** depends on SA-2b (the router regression
+  masking `**`); confirming end-to-end reachability of `../` through a live
+  server would need the router bug fixed first — recommended as the first
+  implementation task either way, since it is a functional break of deep
+  static routes *and* the enabler for the traversal guard test.
+
+## Method notes
+
+Tooling: manual source review of all 41 Vala files in Astralis/src, 31 in
+Statum/src, 16 in Spry/src + tools, Statum/js (3,943 lines) and the
+Invercargill-Sql expression visitor (read-only reference). Git history was
+used to date regressions (EndpointRouter `**` regression 9a3bb18b; prior
+markdown audit f0c967d). No code was modified, no servers were run, and no
+dynamic testing was performed — every finding is static-analysis evidence
+with file:line citations. OWASP mapping uses the 2021 Top 10 categories.