12 KiB
10 — Information disclosure, logging, CLI/operator token handling
Scope: Error-response shaping, request/audit logging, CLI (pic) token handling, dashboard token storage, build/observability surface.
Reviewed at: main (matches AUDIT.md head, v1.1.9).
Counts
| Severity | Count |
|---|---|
| High | 2 |
| Medium | 4 |
| Low | 4 |
| Info | 5 |
Trace samples actually observed
-
Error path (unique-violation on admin create):
manager-core/src/admin_users_api.rs:464-467mapsAdminUserRepositoryError::DuplicateUsername(_) | DuplicateEmail(_)to a 409 whose body isJson({"error": self.to_string()}). The thrown error string is the typed enum variant ("username already taken"), not the rawsqlx/Postgres message; the constraint name and column never reach the client. GenericDb(e)andAuthzRepo(e)paths sanitize to"internal error"while logging the underlying error server-side. Same shape repeats across every*_api.rs(api.rs,apps_api.rs,secrets_api.rs,dead_letters_api.rs,topics_api.rs, …). DB-error leakage is not present. -
Happy-path log emission (login):
manager-core/src/auth_api.rs:75-110. The handler does the Argon2 verify, never logsinput.username/input.password. Error paths usetracing::error!(?err, "admin_users credentials lookup failed")etc. — the field captured is the repo error, not the credentials. The cookie/Bearer token is hashed (hash_token) before any field appears inauth_middleware.rs:204-208; onlytoken_hashever lands intracing::error!.tower_http::trace::TraceLayer::new_for_http()atcrates/picloud/src/lib.rs:593emits the URI but headers (includingAuthorization) are off by default for that layer. -
CLI token write (
pic login):crates/picloud-cli/src/config.rs:92-109write_privateopens the credentials file withOpenOptions::new().mode(0o600)and then re-set_mode(0o600)as belt-and-suspenders. Unit testposix_mode_is_0600(line 137) asserts the file mode at rest. The token is persisted as TOML (token = "pic_...") in plaintext — no symmetric encryption with a passphrase or OS keychain integration, so a stolen home dir reveals the token. Not surprising for a developer-targeted CLI, but worth flagging as Info.
High
H1 — Dashboard bearer token stored in localStorage (XSS-readable)
- Location:
dashboard/src/lib/auth.ts:22-48(TOKEN_KEY = 'picloud.admin.token',localStorage.getItem/setItem). - Detail: The same string used as the
Authorization: Bearervalue AND as the session cookie value lives inlocalStorage. Any XSS anywhere in the SPA (CodeMirror script editor, an in-dashboard log viewer that renders user-controlled JSON, a third-party dep with a prototype-pollution sink, etc.) reads the token in one line of attacker JS and exfiltrates it — the token is bearer-only with no per-request signing, so theft = full account takeover for the saved TTL (default 24 h sliding). The cookie path is described in the file's comments as the "primary" channel ("the bearer token doubles as the cookie value on the server side") but noHttpOnly/Secure/SameSitecookie is actually set by the dashboard —setSession()only writes to the store, then tolocalStorage. The middleware accepts the cookie if Caddy/browser sets one, but the dashboard itself does not. - Fix shape:
- Move the token to memory only (Svelte store, no
localStorageecho) + a siblingHttpOnly; Secure; SameSite=Strictcookie set by/auth/loginserver-side.auth_api.rs:125-145already returns the token; have it alsoSet-Cookieand haveauth_middleware.rs:373-383already reads the cookie. - Or: keep
localStoragebut pin to a refresh token with a 60-300s sliding access token kept in memory. - Adding a CSP
default-src 'self'; script-src 'self'for the dashboard ships materially mitigates the blast radius but does not eliminate it.
- Move the token to memory only (Svelte store, no
H2 — pic admins create/set --password VALUE accepts the password on argv
- Location:
crates/picloud-cli/src/main.rs:558(Create),:576(Set);crates/picloud-cli/src/cmds/admins.rs:50-58. - Detail: Both subcommands take
--password <PASSWORD>as a plainOption<String>. The clap arg is not restricted to-(stdin). When operators do the natural thing (pic admins create alice --password hunter2), the plaintext password ends up in:- the shell history (
~/.bash_history,~/.zsh_history) ps auxfor the lifetime of the process/proc/<pid>/cmdlinefor any other UID on the host while it runspic login --token <T>(main.rs:126) has the same flaw for an API key. The help text warns ("Passing the password inline puts it in shell history — pipe instead when scripting") but the warning is documentation-only.
- the shell history (
- Fix shape:
- Reject any
--passwordvalue other than-outright (return an error with "use--password -to read from stdin"), or - Accept the inline form but actively zero out
argv[N]after parsing (prctl(PR_SET_NAME)on Linux + memset of the underlyingString, both of which require unsafe / nightly tricks forString), or - Drop the inline form entirely and require
--password -. - Same fix for
pic login --token VALUE— make it--token-stdinor just remove the flag (the env-var fallback already covers CI).
- Reject any
Medium
M1 — Distinguishable 404s for "unknown host" vs "no route on host" enable enumeration
- Location:
crates/orchestrator-core/src/api.rs:188-208. - Detail: Two distinct JSON bodies:
"no app claims host {host:?}"when theHostheader doesn't match any app's domain claim."no route matches {method} {path}"when the host is claimed but no route fires. An unauthenticated attacker can enumerate the set of apps installed by walking common subdomains (api.,app.,admin., …) and watching which 404 they receive, even though all such hosts must resolve to the operator's IP. Combined with the/versionpublic_base_urlit also reveals the canonical host the operator picked.
- Fix shape: Collapse both to a single uniform
"no route matches METHOD PATH"(the host case is the same kind of routing miss from the caller's perspective). Log the distinction server-side for debugging; don't expose it to the client.
M2 — pic admins "no echo when interactive" comment is misleading; stdin read does echo
- Location:
crates/picloud-cli/src/cmds/admins.rs:60-83. - Detail:
read_password_from_stdinsays"Prompt without echoing — rpassword would be cleaner, but the CLI already avoids extra crates" but the implementation calls
stdin.lock().read_line(&mut buf)— which does echo characters to the terminal.crates/picloud-cli/src/cmds/login.rs:64usesrpassword::prompt_passwordcorrectly. The mismatch meanspic admins create alice --password -typed at a TTY echoes the password on screen. (rpasswordis already a transitive dep viapic login, so the "avoid extra crates" rationale is moot.) - Fix shape: Use
rpassword::prompt_passwordin the same place. Same applies tocrates/picloud/src/main.rs:203(prompt_password_from_stdin) — alsoread_line-based with an "(will be read from stdin, no echo)" prompt that's a lie.
M3 — ExecError::Runtime / ExecError::Parse strings echoed verbatim into responses
- Location:
crates/orchestrator-core/src/api.rs:745-753;executor-core/src/types.rs:138-160. - Detail: Rhai parse and runtime error strings are passed straight into the HTTP error body (
e.to_string()→ JSON). Rhai's error messages include line/column positions and (for runtime errors) snippets of the script source. For a public unauthenticated route to a script the caller doesn't own, this reveals script internals to the world. The blast radius is limited (Rhai source is uploaded by the script owner so it's "their" data leaking back), but for multi-tenant setups where one app's script is invoked across apps viainvoke()it's a cross-tenant leak. - Fix shape: Two-tier — log full message server-side, return only a stable kind ("script error", "script parse error") plus a correlation id to the client. Keep the verbose form for
/api/v1/admin/scripts/{id}/test(authenticated, intra-app).
M4 — Credentials file is plaintext TOML with no at-rest encryption
- Location:
crates/picloud-cli/src/config.rs:81-89. - Detail: Token sits as
token = "pic_..."in$XDG_CONFIG_HOME/picloud/credentials. Mode 0600 protects against same-host other-UID reads, but does not protect against backup capture, full-disk forensics of a stolen laptop, or a cloud-sync daemon that ignores the mode and uploads it. Several comparable CLIs (gh,gcloud) ship the same plaintext shape, so this is best-practice in line with peers — but worth being explicit about. - Fix shape: Optional: add
pic login --keyringthat stores via OS keychain (Secret Service / Keychain / Credential Manager via thekeyringcrate) and falls back to plaintext when no keychain is reachable. Not required for v1.x.
Low
L1 — prompt_password_from_stdin in picloud admin reset-password echoes the password
- Location:
crates/picloud/src/main.rs:203-216. Prompt advertises "will be read from stdin, no echo" but usesread_line. Same fix as M2.
L2 — Authorization: Bearer pic_… accidentally placed in a URL query would land in tower-http TraceLayer logs
- Location:
crates/picloud/src/lib.rs:593.layer(TraceLayer::new_for_http()). - Detail:
TraceLayerlogs full URI including query string atDEBUG/INFO. The platform doesn't accept tokens via query string today, but the trace layer would log any future?token=or?api_key=parameter. Belt-and-suspenders: add aMakeSpanthat strips known sensitive query keys, OR explicitly document "tokens go in headers, not in queries" in the SDK.
L3 — eprintln! for env-var validation failure logs env-var value
- Location:
crates/manager-core/src/secrets_service.rs:59-62. - Detail:
tracing::warn!(value = %v, "ignoring invalid PICLOUD_SECRET_MAX_VALUE_BYTES …")echoes the operator-supplied value. The env var holds an integer in normal operation, so this is harmless, but the same pattern is copy-pasted across the file caps. If an operator typosPICLOUD_FILES_ROOTas a path containing a secret (e.g. an embedded credential in a network path), it would land in logs. Tiny.
L4 — PICLOUD_HTTP_ALLOW_PRIVATE warning shouts loudly but does not name the listener (good)
- Location:
crates/picloud/src/lib.rs:172-176. Info — included for completeness; the warning is the correct shape.
Info
- I1 —
/healthzis literally"ok"(crates/picloud/src/lib.rs:621). No leak. - I2 —
/versionreturns onlyproduct/sdk/api/schema/wire/public_base_url(crates/picloud/src/lib.rs:632-642). No build SHA, no OS, no internal IP. Good. - I3 — No custom
Serverheader set anywhere. Hyper's defaultServer: hypermay leak via the framework, but no manual augmentation. Info. - I4 — No dedicated admin-action audit log table. Grep for
audit_logfinds onlyexecution_logs(script outcomes) and DL rows. Admin actions like "user X minted API key Y", "user X deleted app Z", "user X promoted user Y to owner" are not durably recorded. Out of scope for this audit (overlaps with agent 1/2) but flagging for the operator-trust story. - I5 — Source maps not emitted in prod.
dashboard/svelte.config.jsusesadapter-static; Vite defaultbuild.sourcemap = false. No leak.
Most important finding (2-3 sentences)
H1 (dashboard token in localStorage) is the biggest single risk: a bearer token good for full admin takeover sits in DOM-readable storage with no XSS mitigation in front of it, and a CSP is not configured. Combined with the CodeMirror-based script editor (which evaluates user-typed Rhai) and a dashboard that renders trigger payloads + execution-log fields from JSONB columns, any one HTML-injection bug becomes a full account takeover. The fix is well-scoped — switch the server-side login response to also set an HttpOnly; Secure; SameSite=Strict cookie, keep an in-memory access token for SPA reloads, and drop the localStorage echo entirely.