|
|
@@ -0,0 +1,757 @@
|
|
|
+# 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.
|
|
|
+
|
|
|
+**Status: PARTIALLY ADDRESSED (approved scope: password changes only).**
|
|
|
+`M0002` adds `spry_users.sessions_valid_after`; `UserService.set_password`
|
|
|
+bumps it (self- and admin-forced changes); `SpryAuth.revalidate` fails
|
|
|
+closed on frames whose signed `as_at` predates the epoch, and
|
|
|
+`ChangePasswordAction` re-mints the slot in the same response so the
|
|
|
+changing user stays logged in while every other device is revoked.
|
|
|
+**Logout still does not revoke** — the human explicitly accepted this
|
|
|
+residual risk for now; revisit by bumping the epoch in `LogoutAction` (one
|
|
|
+line, but it then logs out every device).
|
|
|
+
|
|
|
+**Fix direction (original):** 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).
|
|
|
+
|
|
|
+**Status: ADDRESSED.** The `**` wildcard remainder now comes from the
|
|
|
+request's segments (deep static routes work again), and the guard rejects
|
|
|
+any relative path containing `..`, resolves both root and target
|
|
|
+symlink-free, and requires `resolved == root || resolved.has_prefix(root +
|
|
|
+"/")`. Regression tests cover `../../../etc/passwd`, `%2e%2e%2f`, symlink
|
|
|
+escape, sibling-directory and `/www` vs `/www2` boundaries (38-check Astralis
|
|
|
+suite).
|
|
|
+
|
|
|
+**Fix direction (original):** 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.
|
|
|
+
|
|
|
+**Status: ADDRESSED (core limits).** The MHD daemon starts with
|
|
|
+CONNECTION_LIMIT (256), PER_IP_CONNECTION_LIMIT (32) and
|
|
|
+CONNECTION_MEMORY_LIMIT (128 KiB), all configurable via the `server`
|
|
|
+section; a configurable `max_request_body` (default 10 MiB, 0 disables)
|
|
|
+rejects with 413 on declared Content-Length and defensively while streaming
|
|
|
+in `ServerInput.write`. The FormDataParser whole-body multipart copy and
|
|
|
+O(n·m) boundary scan remain — noted as follow-up work.
|
|
|
+
|
|
|
+**Fix direction (original):** 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.
|
|
|
+
|
|
|
+**Status: ADDRESSED.** `restore_slot` now rejects frames past
|
|
|
+`invalid_after` (new `StateError.FRAME_EXPIRED`, surfaced as an `invalidate`
|
|
|
+directive by `SlotPostEndpoint`) and keeps the snapshot's `as_at` as
|
|
|
+`last_touched`, so restored slots still expire via the cleanup sweep.
|
|
|
+
|
|
|
+**Fix direction (original):** 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.
|
|
|
+
|
|
|
+**Status: NOT ADDRESSED — top follow-up.** SA-5 was not included in the
|
|
|
+approved RFC scope (the endpoint-level guard hooks of SA-6 shipped instead).
|
|
|
+All shipped Spry actions do call the guards (verified per file), and
|
|
|
+disabled/deleted accounts are caught by `revalidate` on every guarded
|
|
|
+request, but nothing yet *forces* a guard on a new action — the framework
|
|
|
+default remains allow.
|
|
|
+
|
|
|
+**Fix direction (original):** 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.
|
|
|
+
|
|
|
+**Status: ADDRESSED (hooks provided, default open).**
|
|
|
+`StatumConfigurator.guard_entrypoint/guard_channel/guard_channel_subscriptions`
|
|
|
+install an `AuthorizationPolicy` consulted by the three endpoints — return
|
|
|
+null to allow, an `HttpResult` (401/redirect) to deny. Spry guards are the
|
|
|
+intended implementations; documented in Statum's getting-started.
|
|
|
+
|
|
|
+**Fix direction (original):** 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).
|
|
|
+
|
|
|
+**Status: ADDRESSED (warn-only, as scoped).** Both `ensure_admin`
|
|
|
+(Spry/src/Auth.vala) and the scaffold's `seed_admin`
|
|
|
+(tools/spry/templates/app-main.vala) now print a loud `warning()` when a
|
|
|
+NEW account is seeded with a password shorter than 12 characters
|
|
|
+(`PasswordPolicy.ADMIN_MIN_LENGTH`) or on the built-in deny-list.
|
|
|
+Seeding is never blocked — the operator's escape hatch — and existing
|
|
|
+accounts are never re-judged. The README example is now a
|
|
|
+generated-looking password with an `unset` and a rotation note.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 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).
|
|
|
+
|
|
|
+**Status: ADDRESSED (except rate limiting, deliberately out of scope).**
|
|
|
+`ProfileValidation.PasswordPolicy`
|
|
|
+(Spry/src/Actions/ProfileValidation.vala) is now the single source:
|
|
|
+8–128 code-point bounds enforced BEFORE any hashing (the Argon2
|
|
|
+memory-amplification cap), plus an 11-entry built-in deny-list compared
|
|
|
+case-insensitively — documented as a floor, not a policy engine. All
|
|
|
+three password-setting sites enforce it identically (RegisterAction,
|
|
|
+ChangePasswordAction, AlterUserAction) with three overridable messages
|
|
|
+(too short / too long / too common). Password *verification* (login,
|
|
|
+current-password check) is intentionally NOT capped: legacy users with
|
|
|
+longer pre-policy passwords must keep logging in. Login rate limiting
|
|
|
+remains a follow-up (explicitly not approved in this pass).
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 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.
|
|
|
+
|
|
|
+**Status: ADDRESSED (single-signer contract).** `verify_frame` asserts
|
|
|
+`frame.signer == signer_id` and fails closed on mismatch; `verify_with_key`
|
|
|
+is documented as the explicit path for a second signer during rotation.
|
|
|
+
|
|
|
+**Fix direction (original):** `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.
|
|
|
+
|
|
|
+**Status: NOT ADDRESSED (explicit human decision).** The human chose to keep
|
|
|
+distinct username/email registration messages for usability; login already
|
|
|
+answers generically. Revisit only if a threat model change demands it.
|
|
|
+
|
|
|
+**Fix direction (original):** 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.
|
|
|
+
|
|
|
+**Status: ADDRESSED.** `statum-genkeys --out <file>` writes the full config
|
|
|
+at mode 0600 (creating parent dirs); without `--out`, stdout carries only
|
|
|
+the public halves in the same JSON shape plus a pointer to `--out`. `spry
|
|
|
+keys` was adapted to spawn `--out` against a 0600 tempfile.
|
|
|
+
|
|
|
+**Fix direction (original):** 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.
|
|
|
+
|
|
|
+**Status: ADDRESSED.** `SecurityHeadersComponent` appends
|
|
|
+`X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`,
|
|
|
+`Referrer-Policy: strict-origin-when-cross-origin` and a CSP template by
|
|
|
+default; endpoint-set headers win; everything is overridable/disableable
|
|
|
+via the `securityHeaders` config section, and HSTS is strictly opt-in.
|
|
|
+
|
|
|
+**Fix direction (original):** 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.
|
|
|
+
|
|
|
+**Status: ADDRESSED for the auth slot (see SA-1's epoch); bearer model
|
|
|
+documented.** `sessions_valid_after` binds the auth slot to server-side
|
|
|
+validity; the general bearer model of other slot types stands as designed.
|
|
|
+
|
|
|
+**Fix direction (original):** 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.
|
|
|
+
|
|
|
+**Status: ADDRESSED (Statum side).** 256 global / 16 per-IP concurrent
|
|
|
+channel connections (released on disconnect, swept when closed) and 64
|
|
|
+subscriptions per channel, over-cap answers 503; connection caps also run
|
|
|
+before the SSE result is created via `ChannelGatewayEndpoint`. The
|
|
|
+Astralis `SseEndpoint` 100 ms keep-alive poll remains as-is (cheap; the
|
|
|
+connection caps bound the total wakeups now).
|
|
|
+
|
|
|
+**Fix direction (original):** 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.
|
|
|
+
|
|
|
+**Status: ADDRESSED (parser, not a binding).** `UserService` now parses
|
|
|
+the stored hash's PHC string (`parse_hash_parameters`) and compares
|
|
|
+algorithm, version and m/t/p against the current MODERATE limits
|
|
|
+(`hash_needs_rehash`; note the units: PHC `m=` is KiB, libsodium's
|
|
|
+MEMLIMIT is bytes). On successful login a stale hash is transparently
|
|
|
+rewritten at current parameters (`rehash_password`) — best-effort, with
|
|
|
+the session-validity epoch deliberately NOT bumped, since no credential
|
|
|
+changed. Malformed/non-PHC hashes answer "needs rehash", making rehash
|
|
|
+the only migration path. Verified live against a real SQLite store
|
|
|
+(`tests/RehashProbe.vala`, wired as a meson test).
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 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.
|
|
|
+
|
|
|
+**Status: ADDRESSED.** All seven actions now route failures through
|
|
|
+`ProfileValidation.internal_error_message(e, generic)`, which logs the
|
|
|
+full `e.message` via `warning()` and returns the (placeholder-free)
|
|
|
+generic constant for the notify text. The `*_failed_message` constants
|
|
|
+lost their `%s`; internal-only detail sites (missing reload, missing
|
|
|
+target user) log their ids server-side instead of interpolating them.
|
|
|
+The duplicate-constraint mapping (which shows a *chosen* message, never
|
|
|
+the SQL text) is unchanged.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 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.
|
|
|
+
|
|
|
+**Status: DOCUMENTED (accepted for now).** Documented in Statum's
|
|
|
+getting-started "Server-side Security" section as a page-author contract:
|
|
|
+expressions are trusted authoring; never bind user data into expression
|
|
|
+*source*. No shipped component does. A whitelist expression parser remains
|
|
|
+possible future work.
|
|
|
+
|
|
|
+**Fix direction (original):** 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.
|