security-audit.md 31 KB

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)

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 <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.