think
16px
820px

name: obscura-securefolder-security-audit-08-08
description: "Claude Security scan of Secure Folders (2026-08-08, run CLAUDE-SECURITY-20260808-043024, rev aec76e1): 35 findings / 10 HIGH. Keys were 0644 on PROD; a Private Vault stored PLAINTEXT; the step-up gate had two bypasses. Top 3 FIXED + deployed; the egress/DLP batch is still OPEN."
metadata:
type: project


Scan products live in CLAUDE-SECURITY-20260808-043024/ (gitignored) at the repo root:
CLAUDE-SECURITY-RESULTS.md is the readable report. Scoped to 37 secure-folder files at
revision aec76e1, effort max.

🔴 Read the report's own caveats before trusting its silence. The max tier's
adversarial pass never produced a single vote (hit usage limits twice), so nothing was
red-teamed — in substance a high-tier result. 87 deduplicated candidate sites were
never panelled at all.
Re-running reviews a different subset, so it genuinely adds
coverage.

FIXED and deployed (main fd955f2, demo verified, suite 67/67)

  1. Keys were world-readable on LIVE hosts. deploy/bootstrap-client.sh chmod 644'd the
    securefolder KEK, the blob at-rest identity and the OIDC secret (license 666) by
    design
    , reasoning that the distroless container (uid 65532) needed it. Confirmed 0644
    on dms1 (prod), dms2 and the demo. 🔴 A bind mount passes host uid through, so
    OWNERSHIP is the answer: chown 65532:65532 + chmod 600.
    Applied to all three hosts
    and boot-verified (readable AND the license still writable as 65532; the demo was
    restarted to prove a real boot). The installer now STOPS if it cannot chown, instead of
    widening the mode. ⚠️ chown without host sudo: docker run --rm -v "$S:/s" <img> chown 65532:65532 /s/f — a root container writes through the mount (valbox has no
    passwordless sudo).
  2. A Private Vault stored PLAINTEXT. passthroughSealer writes e2ee bytes verbatim and
    the only guard refused a container arriving OUTSIDE a vault — never plaintext arriving
    INSIDE one. Any path that skipped the ceremony (new version, attachment, integration
    POST) landed unencrypted in the vault namespace while the tag, icon, gate and refusal
    copy all promised the server could not read it. putMaybeSealed now requires the
    OBSCV1 magic (peek, don't consume); the declared MIME cannot carry this check
    because the caller sets it. Moves are refused across a vault boundary both ways at
    plan time — executeBoundaryMove is best-effort by construction, so an inward move used
    to leave metadata saying "in the vault" and bytes readable in the plain namespace.
  3. 🔴 Two step-up gate bypasses. (a) The middleware resolved the FIRST folder id it
    found, so ?parent_id=<open>&folder_id=<secret> listed the secret folder ungated — it
    now resolves EVERY named id and takes the strictest. (b) All four gate lookups anchor
    on f.id::text = $1 — a STRING compare
    — and Postgres accepts uppercase / hyphen-less
    spellings of the same uuid, so an upper-cased id resolved the folder for the handler and
    an empty chain (= "not gated") for the gate. canonicalUUID normalises at all four.
    ⚠️ I first dismissed (b) as a false positive because folders.id is uuid — the
    column type is irrelevant when the query casts to text. Check the SQL, not the schema.

KEK ROTATION done 2026-08-08 (dms1 + dms2)

🔴 The rotation was FREE because the feature is not live there: secure_folder_keys
does not exist on dms1 or dms2 (migrations 00177-00180 have not run — prod is on 83bd270),
so ZERO folder keys were wrapped under the exposed KEK and the swap needed no rewrap. New
age identities generated ON each host (age-keygen is installed), old key kept at
/home/vdi/securefolder_kek.key.compromised-<ts> (0600 vdi). Prod stayed up; the KEK is
read at boot and nothing was restarted.
⚠️ The DEMO's KEK was NOT rotated — it has 39 wrapped folder keys, so rotating there
needs an unwrap/rewrap pass that no tool implements.

🔴 blob_age.key is the exposure that actually matters and is NOT rotated. It protects
every blob on prod (383 versions, live since 2026-07-14) and was 0644 for the same period.
BackfillEncrypt only encrypts still-PLAINTEXT blobs — it is not a rotation path — so
rotating it means writing a decrypt-old/re-encrypt-new pass over every object. Awaiting a
decision.

Fixed in the second pass (main 003e118, suite 68/68)

  • MCP semantic_search returned verbatim passage text with neither
    refuseSecureFolderMachineRead nor allow_ai_processing. Gated hits are now dropped
    ENTIRELY — headers alone ("these documents matched your query about the layoffs") leak
    the answer without the text. k clamped at 50 (it was the bulk-export lever).
  • MCP preview_document had the secure-folder gate but not allow_ai_processing. A
    picture of the text is the ordinary way around "no AI processing".
  • Attachment download paid NEITHER step-up gate and skipped the DLP download decision.
    ⚠️ downloadAllowed's own doc comment always said it covered "the DOWNLOAD/attachment
    channel" — it was simply never called. The watermark omission there stays deliberate.

Fixed in the third pass (main 305de0a)

🔴 A backslash in a folder name DELETED blobs that were still in use. Six queries asked
"is df.path inside ef.path?" as df.path LIKE ef.path || '/%'. The pattern comes from a
column, so binding cannot escape it — and Postgres' default LIKE escape character is
the backslash
. Proven on pg17: for Root/Legal\Priv + Root/Legal\Priv/Case1, the LIKE
form finds 1 descendant, the correct answer is 2 — Case1 is invisible. In
UnreferencedPlainContentHashes that missing descendant means a live document stops
counting as a reference and the caller deletes a blob still in use. %/_ fail the other
way (Root/AB/Child matched as a child of Root/A%), handing one folder's retention
schedule and transfer scope to an unrelated subtree.

Fix: prefix ARITHMETIC, no pattern semantics — and no cost, since Postgres cannot index a
LIKE whose pattern is not a constant either:
(df.path = ef.path OR left(df.path, length(ef.path)+1) = ef.path || '/').
Guard: scripts/path-prefix-guard.sh (static, no DB) refuses any column-derived LIKE.
⚠️ It first fired on its own comment explaining the hazard — the same trap perm-guard.cjs
has hit twice
— so it skips comment lines.

Fixed in the fourth pass (main 1da7d18, suite 69/69)

  • Records transfer package was gated on records.admin ALONE — that says WHO may
    transfer, not WHICH records, so a holder could package a document they cannot read.
    Now also requireAccess(AccessRead) + both step-up gates. ⚠️ The unwrap chokepoint
    already refused ENCRYPTED folders here (PurposeDownload fails closed); it was the
    step-up-only folder that had no unwrap to refuse.
  • 🔴 The PUBLIC external-signer preview opened content with PurposeSeal — a SYSTEM
    purpose, which by design passes the chokepoint without a step-up — so a vault document
    sent out for signature streamed its decrypted PDF to whoever held the link. It now
    refuses secure folders exactly as a share link does. Its sibling (PublicSignSecureMeta)
    was already safe for the opposite reason: PurposePreview is a USER purpose and fails
    closed with no principal. Rule: a system purpose must never sit behind a public route.
  • ?sign=1 is a QUERY PARAMETER — anyone may set it — and it exempted the placement
    editor from the no-download rule the rest of that handler enforces. Verified live: a
    non-admin reader of a confidential doc now gets dlp.download_blocked; an internal
    doc still reaches the watermark stage (so no regression).
    ⚠️ Testing this needs a NON-owner, non-admin caller — downloadAllowed exempts owners
    and content admins by design, so probing as the director proves nothing.

Fifth pass (main 9cd47a2, suite 69/69)

🔴 The co-agent's bootstrap-admin fix (7959cdb) skipped the accounts it most needed to
check.
It demanded the IdP's email_verified only when the stored provider read "oidc"
— but an SSO sign-in can ADOPT a pre-created account (LinkOIDCIdentityByEmail takes
provider scim/local when email_verified OR sso_claimable) and the adopted row keeps
its original provider
. An admin-prepared, sso_claimable account whose address is
BOOTSTRAP_ADMIN_EMAIL — exactly how a fresh install is set up — arrived as
provider='local' and skipped the check, taking wildcard admin on an unverified claim.
Now demanded of EVERY arrival; finishOIDCLogin is the only caller, so the provider
condition bought nothing. ⚠️ Verify a co-agent's security fix rather than assuming the
finding is closed
— the guard was real, its predicate was wrong.

  • External-signer envelopes are refused on a secure folder at CREATION. Narrowed to
    envelopes carrying an EXTERNAL signer on purpose: /request-signature already has both
    step-up gates, so the gap was an elevated sender exporting the content to an outsider
    by email invite. Internal-only signing still works on a gated folder.
    ⚠️ Verified by build/vet + reading, NOT by a live external ceremony (that needs an
    elevated sender, a real watermarkable PDF and an external invite).

Sixth pass — the tail (main f4c6173, suite 69/69)

  • 🔴 A truncated Private Vault container opened as a shorter file, silently. Chunk
    INDEX is AAD-bound so reordering fails the tag, but dropping chunks off the END just
    ended the read loop early. Anyone who can write to the object store — the operator this
    tier exists to defend against — could cut the last pages off a contract. Fixed by checking
    the recovered total against the authenticated header's size: no format change, and
    pre-existing containers are covered. Proven with scripts/vault-container-integrity.cjs
    (needs a vite DEV server — a production bundle exposes no /src modules).
  • 🔴 The access log recorded r.URL.Path verbatim and several routes carry the
    credential IN the path (/public/sign/{token}, /external-approval/{token},
    /office/content/{token}). The route attr already holds the PATTERN, so the value is
    now masked (any param whose name mentions "token"). Verified live: path shows /***/.
  • 🔴 Step-up had NO attempt budget — six digits against only the generous per-user
    request cap. 5 consecutive failures now lock ELEVATION for 15 min (sign-in untouched, so
    nobody is locked out of the product). Verified live: 6th attempt too_many_attempts, and
    a CORRECT code while locked out is still refused.
    ⚠️ A user with NO TOTP enrolled hits an error path before the counter — nothing to
    brute-force there, but it means you cannot test the budget with an unenrolled account
    (the director on the demo has none).
  • 🔴 ListVaultKeys handed every folder-Manage holder and wildcard admin the
    wrapped_dek + KDF salt + iterations
    — an offline cracking kit for another member's
    vault passphrase. The Manage inventory now carries only who/credential/kind/when, which
    is all the UI ever read; material is served solely by /vault-keys/mine.
  • The Casdoor client secret in docs/DEPLOYMENT.md is now a placeholder. ⚠️ The
    secret itself still needs rotating in Casdoor
    — it is in the git history and in every
    clone; redacting the file does not un-publish it.

Still OPEN (ranked)

  • document copy isn't behind requireFolderStepUp; ZIP lacks the classification gate
    (it has the folder one).
  • Gotenberg SSRF (deploy/docker-compose.yml) — the only one left. The deny-list has
    been hardened and its own comment already states the limit: a deny-list cannot be proven
    complete, and it matches URL TEXT, so 169.254.169.254.nip.io resolves past it. The
    robust form is --chromium-allow-list, deferred for a stated PRODUCT reason (it breaks
    letterheads referencing an outside image). Network isolation would cut internal-service
    reach without touching letterheads, but not link-local metadata. Needs the owner's
    call, not a silent fix.

🔴 Assume the KEK is compromised on dms1/dms2 — it sat 0644 for a day. Re-keying is the
honest call and has not been done. Related: [[obscura-secure-folders]],
[[obscura-backup-encryption-crosspull]], [[obscura-mcp-module]], [[obscura-dlp-ai-processing]].