# 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. **Status: ADDRESSED (warning, no refusal).** Statum's config load now stats every file that contributed the merged "statum" section (following symlinks) and prints a loud warning naming the file, its mode and the `chmod 600` fix when it is group/world-readable AND the section actually carries a secret key. It deliberately does not refuse: refusal would brick 0644-config dev setups and image-baked containers (rationale in Statum/src/Statum.vala). Env-var secret injection remains unimplemented. --- ## 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 ` (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.