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).
auth frame
stays valid and re-authenticates the user (Spry/Statum).FilesystemResource traversal guard is a prefix check with
a non-symlink-resolving canonicaliser (Astralis).invalid_after unenforced on the write path).require_login/require_permission are opt-in per
action, and nothing else checks (Statum endpoints unauthenticated).EntrypointEndpoint and the SSE channel are callable
unauthenticated (info disclosure / DoS surface).change-me documented as the example; no forced rotation).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.SigningProvider.verify_frame ignores the frame's
signer claim (weakens multi-signer deployments; fine for single-signer).statum-genkeys prints secret keys to stdout.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).e.message printf'd into notify text).new Function('with(scope){...}') in statum.js compiles
attribute expressions — a page-authored template injection primitive, safe
only while page markup is fully trusted.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:
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.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.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./_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).
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:
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./ (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.
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.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.
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.
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:
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.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.
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.
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.
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
LoginAction — online brute force is
unthrottled (only Argon2's cost slows the attacker, and it slows the
defender equally).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).
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.
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.
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:
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.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).
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.
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).
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.
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.
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).
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.
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.
Recorded so future audits don't re-derive them:
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.)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.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).Html.ParserOption.NONET (MarkupTemplate.vala:75-80); no runtime
string interpolation into markup.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).**); 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.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.