think
16px
820px

Claude Security results — redacted summary

Whole-repository scan of obscura at commit e1839a08 on main (working tree dirty), run 2026-07-29 at medium effort with attack-surface focus across 1,230 tracked files. 33 findings survived verification: 4 HIGH, 29 MEDIUM.

This is the redacted copy. Exploit walkthroughs, preconditions and code snippets have been removed because this file is shared over the network. The full report — with the reproduction detail needed to confirm and fix each finding — stays local at CLAUDE-SECURITY-20260729-063750/CLAUDE-SECURITY-RESULTS.md in the repository.

Every finding below was derived by reading code. No tests were run, no exploit was fired, and no proof-of-concept was executed.

Coverage

Twelve components researched across eight vulnerability categories (48 researcher passes, all returned), plus a breadth sweep and a secrets pass: http-api-server, auth-rbac, dms-core, esign-and-crypto, sharing-content-acl, correspondence-workflow, ai-module, background-jobs, licensing-and-migrations, stego-sidecars, web-frontend, deploy-and-scripts.

The completeness check passed — every top-level directory was either scanned or explicitly skipped. Deliberately not examined: api/ (OpenAPI spec only), docs/ (documentation; still read by the secrets pass, which is how F1 surfaced), proto/ (schema definitions, reviewed via generated consumers), mobile/ (roadmap placeholder), prototype/ (discarded UI variants), and *_test.go (background context, not production surface).

The largest gap: researchers produced 96 deduplicated candidates and the verification panel reviewed 45 of them — 51 were never voted on, because the run reached the panel's review cap. Those are neither confirmed nor refuted and are absent here, so an area's absence from this report is not evidence that it is clean.

Findings

F1 — Production Casdoor OIDC confidential-client secret committed in the deployment runbook (HIGH, confidence high)

Impact. Anyone with read access to this repository (contractors, forks, mirrors, CI caches, an AI coding agent's context) holds the confidential-client credential for the organization's live SSO tenant. With client_id + client_secret an attacker can authenticate to the IdP's token endpoint as the Obscura application — redeeming authorization codes it intercepts, and, depending on the Casdoor client's grant configuration, obtaining tokens for the application's own scopes. The secret is also the backend's proof of identity in the authorization-code exchange (LoginOIDCCode), so its disclosure removes the only server-side authentication of that flow.

Where. docs/DEPLOYMENT.md:84

What. The production runbook hard-codes a 40-hex OIDC client secret and instructs the operator to write it into deploy/secrets/oidc_client_secret — the exact file docker-compose.prod.yml mounts at OIDC_CLIENT_SECRET_FILE, which config.OIDCConfig.ClientSecret loads and wire.go hands to authadapters.NewOIDCVerifier. The matching issuer and client_id are published two sections below and in deploy/prod.env.example (both redacted here), so the full confidential-client credential triple lives in version control.

Fix. Rotate the Casdoor client secret in the IdP immediately and treat the current value as burned. Remove the literal from docs/DEPLOYMENT.md (and purge it from git history), replacing it with printf '%s' '<paste the client secret from Casdoor>' > secrets/oidc_client_secret. Add a repo-wide secret scanner to CI so a 40-hex literal next to the word secret fails the build.

Verification. panel-confirmed

F2 — Unauthenticated PDF-parsing resource exhaustion: canonical() recurses over indirect references with a depth bound but unbounded fan-out and no cycle detection (HIGH, confidence high)

Impact. A single small HTTP request (a few hundred bytes of PDF) drives the server into effectively unbounded CPU and heap allocation. The strings.Join accumulation grows geometrically per level, so the process OOMs or hangs; the recover() in renderFingerprint catches panics, not memory exhaustion, and the per-page ltvMaxFingerLen check runs only after the exploding call returns. Result is total loss of availability for every user, from an unauthenticated client.

Where. go/internal/esign/adapters/pdfltv.go:255 in canonical

What. An attacker-supplied PDF uploaded to the unauthenticated POST /api/v1/verify reaches PdfSigner.Verify -> appendedIsLTVOnly -> renderFingerprint -> canonical, which walks resolved PDF objects with only a depth cap (ltvMaxDepth=12) and no visited-set; digitorus/pdf's Value.Index/Value.Key resolve indirect references with no memoisation and no self-reference guard, so a self-referential array explodes to fan-out^12 recursive parses and string joins.

Fix. Add a visited-object set (keyed on the resolved objptr) to canonical() so a reference cycle terminates, and enforce a hard node/byte budget threaded through the recursion (checked on every call, not once per page) so the fingerprint aborts and returns ok=false as soon as the budget is exhausted. Consider also bounding total streamDigest reads per document rather than per stream.

Verification. panel-confirmed

F3 — Unauthenticated PDF verification multiplies attacker-declared /ByteRange segments into unbounded heap (memory-exhaustion crash) (HIGH, confidence high)

Impact. One ~2-5 MB HTTP request from an unauthenticated client drives hundreds of gigabytes of allocation in a single handler goroutine. Go's out-of-memory condition is a runtime throw (chi's middleware.Recoverer cannot catch it) or an external OOM kill, so the whole single-node server dies for every user and can be re-killed at will.

Where. go/internal/esign/adapters/pdfsign.go:293 in (*PdfSigner).Verify

What. An anonymous multipart upload to POST /api/v1/verify reaches this line with fully attacker-controlled PDF bytes; the forked verifier it calls (go/third_party/pdfsign/verify/signature.go:105, processByteRange) appends one file-sized read per /ByteRange pair into p7.Content with no cap on the pair count, on the segment lengths, or on the number of signature dictionaries.

Fix. Bound the work before handing bytes to the verifier: reject inputs above an explicit size limit in Service.Verify, and patch processByteRange in the in-repo fork to (a) require the canonical 4-element ByteRange, (b) reject segments whose start/length fall outside [0,size], and (c) cap total accumulated content at the file size. Also add an http.MaxBytesReader to the public /verify handler.

Verification. panel-confirmed

F4 — Unauthenticated memory-amplification DoS: attacker-controlled /ByteRange array is replayed into an unbounded in-memory buffer (HIGH, confidence medium)

Impact. A single ~1 MB upload declaring ~100k ByteRange pairs of [0 ] forces ~100 GB of allocation (amplification is roughly filesize^2/20), OOM-killing the monolith and taking the whole DMS offline. No account, no user interaction, default deployment; the per-IP verify rate limit does not help because one request suffices, and the recover() in VerifyWithOptions cannot catch an out-of-memory abort.

Where. go/third_party/pdfsign/verify/signature.go:100 in processByteRange

What. The uploaded PDF's /ByteRange array (fully attacker-controlled, unvalidated, unbounded length) drives a loop that reads each declared segment out of the file with io.ReadAll and concatenates it into p7.Content; the file arrives from the unauthenticated public endpoint POST /api/v1/verify.

Fix. Before the read loop, reject any ByteRange that is not the canonical 4-element form, and bound each segment: require n == 4, start0 == 0, non-negative lengths, start1 >= len0, start1+len1 == size, and cap the total bytes hashed at the file size. Additionally wrap the /verify route body in http.MaxBytesReader with an explicit artifact-size cap.

Verification. panel-confirmed

F5 — Provisioning script chmods the at-rest blob-encryption master key (and the OIDC client secret) to world-readable 0644 (MEDIUM, confidence medium)

Impact. Any unprivileged local user or side-loaded container on the host can read the age X25519 secret key and decrypt every document blob in object storage (and every age-wrapped e-sign private key in the database), defeating encryption-at-rest entirely; the same line pattern exposes the OIDC client secret, which permits impersonating the application to the identity provider. The observed working tree confirms the effect: deploy/secrets/blob_age.key is mode 0644.

Where. deploy/bootstrap-client.sh:120 in bootstrap-client.sh (step 4: permissions)

What. The AGE-SECRET-KEY generated at line 86 is the deployment's single encryption-at-rest master key (BLOB_ENCRYPTION_KEY_FILE, and the same key the esign key cipher uses for private-key blobs); the script deliberately makes it world-readable so the distroless UID 65532 container can read it, and does the same for the OIDC client secret at line 121.

Fix. Give the secrets to the container without giving them to every local account: chown the files to the container UID (65532) and chmod 0400/0440, or mount them via docker/compose secrets, or run the container with a supplementary group that owns the files at 0640. World-readable is not required for a non-root container to read a file it owns.

Verification. panel-confirmed

F6 — CopyDocument skips the destination-folder capability check when new_folder_id is omitted, letting a Viewer plant content into a folder they cannot contribute to (MEDIUM, confidence high)

Impact. A user holding only Viewer (AccessRead, tier 1) on one document can create a new document inside that document's folder even when they hold no access at all on the folder — bypassing the folder capability ladder that CreateDocument (handlers_dms.go:101-111), MoveDocument (handlers_dms_nav.go:287-297) and FinalizeDocument (handlers_finalize.go:141-150) all enforce with Contributor (AccessReadWrite). The planted copy inherits the destination folder's ACL, so it also re-exposes the source document's bytes to everyone who can read that folder — including when the source itself had inheritance detached and a narrower ACL.

Where. go/internal/httpapi/handlers_dms.go:262 in CopyDocument

What. The untrusted source is the request body's optional new_folder_id (absent/null on the default "copy in place" path); the sink is dms.CopyDocument, which then files the new document into src.FolderID (dms/app/service.go:2274-2280) with no folder-access decision ever made. guardDestinationFolder returns immediately for a nil pointer (content_access.go:125), so the only gate that ran is the route's requireAccess(AccessRead) on the SOURCE document.

Fix. Resolve the effective destination in the handler before the guard, exactly as FinalizeDocument does: load the source document, set folder := body.NewFolderID; if folder == nil { folder = src.FolderID }, run guardDestinationFolder(ctx, p, folder), and pass the resolved pointer to dms.CopyDocument so the service never re-derives an unchecked destination.

Verification. panel-confirmed

F7 — SMTP header injection in external-signer invite email: document title flows unescaped into the Subject header (MEDIUM, confidence high)

Impact. An authenticated user with read-write access to a document can make the server emit an email from the organisation's own SMTP relay, to any address they choose, with attacker-controlled headers (Reply-To, Content-Type, MIME structure) and a fully attacker-controlled body after an injected blank line. That is a high-credibility phishing/spoofing primitive using the org's mail identity and SPF/DKIM alignment. The envelope recipients are unaffected (net/smtp validateLine protects Mail/Rcpt), but everything inside DATA is attacker-writable.

Where. go/internal/esign/adapters/inviter_email.go:55 in (*EmailInviter).send

What. The document title (fully attacker-controlled: dms CreateDocument only rejects an empty title) becomes the invite Subject and is concatenated straight into the RFC822 header block; the CRLF normalisation on line 99 turns any bare \n the attacker embeds into a real \r\n, so arbitrary headers and an arbitrary message body can be injected into mail sent from the organisation's SMTP relay.

Fix. Strip or reject CR/LF (and any control characters) in every value interpolated into the header block — subject, and defensively to/from — before building msg; e.g. build headers with net/textproto/mime encoding (mime.QEncoding.Encode for the subject) and validate the title at the DMS boundary.

Verification. panel-confirmed

F8 — Share-link one-time access codes are submitted over unauthenticated, unencrypted SMTP; SMTP_TLS/SMTP_USER/SMTP_PASS are silently discarded (MEDIUM, confidence high)

Impact. A one-time code that gates an otp/allowlist-protected share link (and the account e-mail-verification token from handlers_auth.go:233/272) traverses the network in plaintext. Anyone able to observe traffic between the app and the mail relay reads the live OTP and, combined with the share token, opens a document that was explicitly restricted to a verified allowlisted recipient. The silently-ignored SMTP_TLS/SMTP_USER/SMTP_PASS config makes this invisible to the operator who set them.

Where. go/internal/notify/adapters/smtp.go:50 in (*SMTPChannel).Send

What. The public share OTP (handlers_sharing.go:316 s.notify.SendEmailOnly) is delivered through this channel, which opens a bare smtp.Dial with no STARTTLS and no AUTH; the configured SMTP_TLS/SMTP_USER/SMTP_PASS values are never passed to the adapter (wire.go:488 constructs SMTPConfig{Host, Port, From} only), so an operator who configures encrypted submission still gets cleartext.

Fix. Extend notify/adapters.SMTPConfig with User/Pass/TLS, wire cfg.SMTP through in wire.go, and honour them: dial tls.Dial for TLS=tls, call client.StartTLS for TLS=starttls, and client.Auth(smtp.PlainAuth(...)) when User is set — matching what internal/esign/adapters/inviter_email.go already does. Fail loudly rather than silently downgrading when TLS is requested but unavailable.

Verification. panel-confirmed

F9 — Quadratic-backtracking regex in extract-sidecar strip_html wedges the single-worker event loop on an uploaded HTML file (MEDIUM, confidence high)

Impact. One authenticated upload indefinitely stalls the shared extract sidecar: content extraction, AI upload analysis and server-side Secure Preview rasterization all stop for every user until the container is restarted.

Where. deploy/extract-sidecar/app.py:174 in strip_html

What. Untrusted source: the raw bytes of any user-uploaded .html/.htm document (or any upload whose caller-supplied mime/filename says html) arrive at /extract and reach strip_html via dispatch. Sink: a lazy .*? between two literals with re.S, which for input containing many <script/<style openings and no matching closer costs O(starts x remaining) — quadratic in the file size.

Fix. Bound the input before regexing (e.g. cap data for the HTML path at a few hundred KB), replace the tag-stripping regexes with a real streaming HTML parser (html.parser/selectolax), and move the CPU-bound dispatch off the event loop (def endpoint or run_in_threadpool) so one hostile file cannot block the whole worker.

Verification. panel-confirmed

F10 — Unbounded, never-evicted process-global PDF cache keyed on user-controlled letter metadata (letterPreviewCache) (MEDIUM, confidence high)

Impact. An ordinary authenticated letter author can grow the server's heap without bound (one full PDF retained per distinct subject string), driving the whole process to OOM — taking down every tenant surface, not just correspondence. Even benign use leaks: each draft edit and each new day bucket adds a permanently-retained render.

Where. go/internal/httpapi/handlers_office_subjects.go:110 in sourceLetterPreviewPDF

What. The cache key is letter.DraftDocxHash + "|" + mergeFieldsFingerprint(fields) where fields folds in PERIHAL (the letter subject) and SIFAT (classification) — both freely rewritable by any holder of correspondence.write via PATCH /letters/{letterID} — and the value is a whole rendered PDF stored in a process-global sync.Map that has no eviction, TTL, or size cap (only Load/Store exist anywhere in the tree).

Fix. Replace the raw sync.Map with a bounded cache (size cap + LRU eviction and/or TTL), keyed only on content-addressed inputs, and cap the retained byte total the way previewSessions does (previewCacheCap). Apply the same fix to letterheadPreviewCache (handlers_office_subjects.go:570), which has the identical never-evicted shape.

Verification. panel-confirmed

F11 — Audit chain HMAC is downgradeable: VerifyRange picks the hash algorithm from the row's own attacker-writable key_epoch column (MEDIUM, confidence high)

Impact. The advertised protection of the keyed audit chain is void. config/requirements.go states AUDIT_CHAIN_KEY means "anyone with database write access can rewrite audit history and recompute valid hashes" is prevented, and /admin/compliance/status reports audit_chain_keyed=true, while the verifier will still accept freshly appended (or, if the append-only trigger is disabled by the table owner, rewritten) epoch-0 rows as clean. An operator or auditor reading a green "chain verified, keyed" result gets no signal that fabricated or laundered entries were accepted — non-repudiation and the records-compliance evidence trail are lost.

Where. go/internal/audit/adapters/pg.go:234 in Store.VerifyRange

What. The tamper-evidence verifier selects between HMAC-SHA256(chainKey) and plain unkeyed SHA-256 based on key_epoch, a column stored in the same audit_events row it is supposed to authenticate and which is not itself covered by the MAC (domain.ChainInput carries only Seq/PendingID/Actor/Action/Resource/Payload). An attacker with write access to the audit table — exactly the adversary the keyed chain exists to defeat — writes forged rows with key_epoch = 0 plus a self-computed unkeyed SHA-256 and VerifyRange re-derives them as valid.

Fix. Bind the algorithm selector to the MAC and enforce a monotonic epoch floor. (1) Include key_epoch (and partition) in domain.ChainInput so it is covered by the hash. (2) In VerifyRange, when s.chainKey is configured, reject any row whose key_epoch is below the partition's established floor — persist the first seq at which the partition became keyed (or derive it as min(seq) with key_epoch>=1) and treat any later epoch-0 row as a break rather than verifying it with domain.Hash. (3) Make domain.HashKeyed return an error instead of silently falling back to unkeyed SHA-256 when the key is empty (chain.go:36-42), so a lost key can never be mistaken for a legacy row.

Verification. panel-confirmed

F12 — PAdES verifier reports an unverified, unauthenticated-attribute RFC3161 token as an authoritative "tsa" signing time (MEDIUM, confidence high)

Impact. The non-repudiable signing time - the one property an RFC3161 timestamp exists to provide - can be set to an arbitrary value on a document that the verifier simultaneously certifies as intact and issued by the trusted in-house CA. This lets a document be antedated or postdated (backdated contracts, disputed deadlines) while every trust indicator on the verification page stays green.

Where. go/internal/esign/adapters/pdfsign.go:356 in (*PdfSigner).Verify

What. The RFC3161 token comes from the CMS SignerInfo's UnauthenticatedAttributes of an attacker-supplied PDF (third_party/pdfsign/verify/signature.go:132), i.e. bytes that the document signature provably does not cover, yet Verify promotes its Time to TimeSource="tsa" / TimestampPresent=true without ever consulting the library's Signer.TimestampTrusted trust result.

Fix. Gate the authoritative branch on the library's own trust result and on an anchor Obscura controls: require sg.TimestampTrusted (or re-verify the token's CMS signature and chain the TSA certificate to the in-house CA pool already loaded into roots, checking the critical id-kp-timeStamping EKU issued by IssueTSACert). When the token is present but untrusted, surface it as an untrusted/claimed time with a warning rather than as TimeSource="tsa", and propagate Signer.TimeWarnings into the SignatureCheck so the UI can show it.

Verification. panel-confirmed

F13 — Declassify guard ranks classifications with a hardcoded 5-label switch, so any admin-created registry level bypasses the Manager-only check (MEDIUM, confidence high)

Impact. A user holding only Editor (tier 3) access on a document classified with any custom registry level can relabel it to a permissive level without the Manager (tier 4) access the guard is meant to require. Since the DLP gates key on the stored code — CreateShareLink checks policy.AllowForward (handlers_sharing.go:97) and downloadAllowed checks policy.AllowDownload (content_access.go:169) — the reclassification turns on the download channel and external share-link minting that the original level's DLP policy denied, and also changes the forensic-watermark mode selected per classification.

Where. go/internal/httpapi/handlers_dms_content.go:129 in SetDocumentClassification

What. The attacker-supplied body.Classification and the document's stored code are both funnelled through classificationRank (handlers_dms_content.go:147), a hardcoded switch that only knows secret/confidential/rahasia/public and returns 0 for everything else — but classifications are an admin-managed registry with its own rank column (migration 00057, go/internal/sharing/domain/classification.go). Any level outside those five ranks 0, so the "lowering sensitivity requires Manager" guard never fires for it.

Fix. Resolve both the current and the requested code's sensitivity from the classification registry (sharing.Service.ListClassifications / GetClassificationClassification.Rank) rather than the hardcoded switch, and fail closed: treat a code that cannot be resolved as maximally sensitive so an unknown source level still triggers the Manager requirement.

Verification. panel-confirmed

F14 — Declassify guard uses a stale hardcoded rank table, so an Editor can strip a document's DLP no-download/no-forward policy (MEDIUM, confidence high)

Impact. A user holding only Editor (tier 3) access on a document — below the Manager tier the guard is meant to require — can move it from a restricted admin-defined classification to a permissive one. That flips sharing's DLP decision for the document: downloadAllowed, the folder-zip filter, securePreviewRequired, the SharedAccess allow_download gate and the CreateShareLink allow_forward gate all read the policy keyed by the new code, so the document becomes downloadable and externally shareable in one PUT.

Where. go/internal/httpapi/handlers_dms_content.go:129 in SetDocumentClassification

What. The attacker-controlled body.Classification is compared against the document's current classification with classificationRank, a hardcoded four-entry table (secret/confidential/rahasia/public), while classifications are an admin-managed registry (classifications.rank, migration 00057) that DLP policies key on. Any admin-created level ranks 0, so lowering out of it is not detected as a declassify and the Manager-only guard never fires.

Fix. Resolve sensitivity from the registry instead of the hardcoded table: look up both the current and requested codes via the sharing service (GetClassification) and compare their stored rank, failing closed (treat an unresolvable code as maximally sensitive) so a missing row cannot be used to sidestep the check. Consider also refusing any change that moves a document to a strictly more permissive DLP policy without AccessManage, independent of rank.

Verification. panel-confirmed

F15 — Raw share-link bearer tokens are written verbatim into the HTTP access log (MEDIUM, confidence high)

Impact. Anyone who can read application logs (container stdout, a log shipper, a monitoring/ops role, an off-box aggregator, a log backup) recovers live share tokens and can replay them to fetch or download the shared documents without any account. The same middleware also captures /preview-session/{sid}/page/{n} capability tokens, /office/content/{token} and /public/sign/{token}, all of which are bearer credentials in the path.

Where. go/internal/httpapi/logging.go:38 in requestLog

What. The share-link credential is carried in the URL PATH (/api/v1/shared/{token}, plus /shared/{token}/preview|meta|otp/send|unlock), and the global access-log middleware logs r.URL.Path verbatim for every request, so every live share token — the sole credential for unauthenticated document access — is persisted into application logs.

Fix. Redact secret path segments before logging: log the chi route PATTERN only (already captured as route) and either drop path entirely for token-bearing routes or substitute the parameter with a fixed placeholder / a truncated sha256 of the token, mirroring the hashPublicToken treatment already used for rate-limit keys.

Verification. panel-confirmed

F16 — Any correspondence.write holder can seal a signature onto another user's numbered letter — the ceremony is gated on READ (mayReadLetter), not on the letter-edit gate (MEDIUM, confidence high)

Impact. An arbitrary member can alter the official, numbered PDF of somebody else's letter: a new seal revision becomes the letter's served content (UpdateLetterContentHash), so every subsequent download/preview/verify of that official correspondence carries an unauthorized signature appearance and certificate. It also poisons the seal chain (esign.no_downgrade / already_signed then constrain the legitimate signer).

Where. go/internal/httpapi/handlers_correspondence.go:1186 in SignLetter

What. The untrusted source is the {letterID} URL param on POST /letters/{letterID}/sign (route perm correspondence.write, a default member permission per wire.go:1425). The only per-letter check is mayReadLetter, which returns true for EVERY non-confidential letter (letter_access.go:24-25); the internal-tier branch has no capability check at all, and the sink sealAndLand -> WriteLetterSeal (correspondence/app/service.go:779) swaps letters.content_hash with the attacker's signed PDF without any ownership check.

Fix. Gate the letter ceremonies the way documents are gated (AccessReadWrite): require mayEditLetter OR that the caller holds a pending sign/meterai/stamp task on an active instance over the letter (or a rank/admin override), not merely mayReadLetter. Apply the same to AffixLetterMeterai and AffixLetterStamp, whose meterai.affix/stamp.affix checks are org-wide capabilities, not per-letter authority.

Verification. panel-confirmed

F17 — Any correspondence.write holder can delete (and add) attachments on another user's letter — destructive write gated only on read (MEDIUM, confidence high)

Impact. Silent destruction of another user's official correspondence enclosures (lampiran) on any letter that is not yet dispatched, and — through the same read-only gate on AddLetterAttachment (line 59) — the ability to plant files onto someone else's letter record. Every other letter lifecycle mutation (number, submit, mark-sent, metadata PATCH, revoke disposisi) is correctly gated on mayEditLetter; this one is not.

Where. go/internal/httpapi/handlers_letter_attachments.go:147 in DeleteLetterAttachment

What. The untrusted source is {letterID}/{attachmentID} on DELETE /letters/{letterID}/attachments/{attachmentID} (route perm correspondence.write, default member). The handler checks mayReadLetter only — which is true for every non-confidential letter — and the service method DeleteLetterAttachment (correspondence/app/service.go:1653) takes no principal and performs no ownership check, so the row is removed for any reader.

Fix. Require mayEditLetter (creator / correspondence.admin) — or at least the uploader's own identity for the delete — on both AddLetterAttachment and DeleteLetterAttachment, keeping mayReadLetter as the additional confidentiality gate; pass the principal into the service method and enforce it there as defense in depth.

Verification. panel-confirmed

F18 — Decompression bomb: docx letter numbering expands every ZIP entry into memory with no size bound (MEDIUM, confidence high)

Impact. Any user who can create letters can make the single Go server process allocate tens of gigabytes on demand and be OOM-killed, taking down the whole DMS for every user. Because the merged docx is also re-zipped and Put into the blob store, a survivor run additionally amplifies storage. The 50 MB nginx client_max_body_size caps the compressed input, not the ~1000:1 deflate expansion.

Where. go/internal/correspondence/app/service.go:1235 in assignNumberDocx

What. The bytes in draft are the letter's attacker-uploaded .docx blob (UploadLetter validates only the .docx extension and the PK\x03\x04 magic), and docx.ReplaceFields decompresses every ZIP entry with an unbounded io.ReadAll (platform/docx/replace.go:78) before the string-surgery pass makes several more full copies. Nothing between the upload and this call bounds the decompressed size.

Fix. Bound the decompression, not just the upload. Give docx.ReplaceFields / HasFields / Validate a maximum total decompressed size (the codebase already has this pattern at internal/httpapi/handlers_office_fonts.go:31, maxZipTotal = 200 << 20, and at :179 io.ReadAll(io.LimitReader(rc, ...))): read each entry through io.LimitReader(rc, remaining+1), accumulate across entries, and return an error once the budget is exceeded. Also reject entries whose declared UncompressedSize64 already exceeds the budget before reading, and cap len(zr.File). Apply the same bound at the two sibling call sites (handlers_office_subjects.go:99 and handlers_official_copy.go:163) so the guard cannot be bypassed through the letterhead/official-copy routes.

Verification. panel-confirmed

F19 — Admin-settable AI base_url replays the deployment's provider API key to any attacker-chosen public host (ai.manage → credential theft) (MEDIUM, confidence high)

Impact. A principal holding only ai.manage (catalogued in web/src/features/admin/permissionCatalog.ts:154 as "Set AI chat retention and usage limits, and review usage") recovers the organization's LLM provider API key — a credential the system deliberately never returns (write-only field, age-encrypted at rest, masked tail only). Beyond billing/abuse of the provider account, every subsequent AI call keeps flowing to the attacker's endpoint: ask-the-archive document excerpts, summaries, and KTP identity-card images (handlers_esign.go OCRPSrEKTP → ai.KTPOCR) are streamed to a host of their choosing until an operator notices.

Where. go/internal/ai/adapters/openai.go:60 in Complete

What. The untrusted source is base_url from PUT /admin/ai/provider (handlers_ai_provider.go:99 → app/provider.go:234 s.newProvider), and the sink is the outbound request that attaches the deployment's provider credential as Authorization: Bearer (and x-api-key in anthropic.go:55). The dial guard in adapters/ssrf.go only refuses NON-PUBLIC addresses, so a base_url pointing at an attacker-controlled public host is dialled normally and receives the key that the API otherwise never discloses (age-encrypted at rest, returned only as a 4-char masked tail).

Fix. Do not replay a stored/env credential to a host the administrator just typed. Either (a) constrain admin-supplied base_url to an operator-maintained allowlist (env-configured, alongside SetTrustedBaseURL) so only vetted endpoints can receive the key, or (b) require a NEW api_key in the same request whenever base_url changes and refuse to inherit the env/stored key for an unrecognised host. Separately, make isTrustedHost compare scheme+host+port rather than hostname alone (ssrf.go:67-73): today an admin-typed URL reusing the deployment host's name but a different port disables the dial guard entirely for that host.

Verification. panel-confirmed

F20 — Keyed audit chain is downgradable: the verifier picks HMAC vs unkeyed SHA-256 from the row's own key_epoch column (MEDIUM, confidence high)

Impact. The tamper-evidence guarantee advertised by AUDIT_CHAIN_KEY ("anyone with database write access can rewrite audit history and recompute valid hashes" is stated as the UNKEYED risk in requirements.go) does not hold. An attacker with database write access can append fabricated audit entries — or, after disabling the owner-droppable triggers, rewrite whole ranges — stamped key_epoch=0 with a correct unkeyed SHA-256 chain, and both the scheduled audit.verify_chain job and POST /admin/audit/verify report the chain as OK. Audit evidence used for compliance/forensics becomes unreliable while appearing verified.

Where. go/internal/audit/adapters/pg.go:237 in (*Store).VerifyRange

What. VerifyRange selects the chain algorithm from key_epoch, a column stored in the same attacker-writable audit_events row it is verifying (migration 00127 added it with DEFAULT 0); a row stamped key_epoch=0 is re-derived with the unkeyed SHA-256 in domain.Hash, so the AUDIT_CHAIN_KEY that is supposed to make the log forge-resistant against a database-write compromise can be bypassed by simply writing epoch-0 rows.

Fix. Do not take the algorithm selector from the data being authenticated. Record the trust decision out of band: e.g. persist (or configure) the first seq per partition at which keying began and treat any row at or after it with key_epoch=0 as a break; at minimum, when a chain key is configured, refuse to accept epoch-0 rows whose seq is greater than the highest epoch-0 seq observed at the time keying was enabled.

Verification. panel-confirmed

F21 — DLP no-download control bypassed by appending ?sign=1 to the inline preview endpoint (MEDIUM, confidence high)

Impact. A user holding only read access to a document in a no-download DLP classification obtains the full PDF bytes, defeating the allow_download=false control that DownloadVersion (handlers_dms.go:337-343) and the plain preview path (handlers_preview.go:246) both enforce. On a deployment without the watermarking module s.protection is nil, so egressProtectPDF returns the bytes completely unmarked (handlers_dms.go:422-424) — an untraceable copy of a document the policy says must never leave.

Where. go/internal/httpapi/handlers_preview.go:229 in PreviewVersion

What. The attacker-controlled query parameter sign (read at line 226 from r.URL.Query()) selects a branch that skips the securePreviewRequired() check and streams the complete PDF, while the route itself is gated only on AccessRead (server.go:870) — no check that the caller is a signature-ceremony participant, and no allow_download check like the one DownloadVersion enforces.

Fix. Do not let a bare query parameter select the byte-serving branch. Either (a) require the caller to be a participant of an open signing ceremony on that document/version before honouring sign=1, or (b) apply the DLP gate to the sign path as well: when securePreviewRequired(ctx, docID) is true, refuse sign=1 for principals who fail downloadAllowed(), and drive the placement editor from the secure image session (SecurePreviewMeta already supports ?sign=1) rather than from raw PDF bytes.

Verification. panel-confirmed

F22 — Pre-update production database dumps are written unencrypted and group/world-readable (MEDIUM, confidence medium)

Impact. A full copy of the application database — user records and credential material, sessions/API keys, signing and audit records, extracted document text in content_text — sits on disk readable by every local account and by any process running as the deploy group, with no expiry and no encryption.

Where. deploy/update.sh:107 in main (step 1: pre-update database snapshot)

What. Every run of the update path writes a complete plaintext pg_dump of the live database into deploy/pre-update/ using the invoking shell's default umask; no umask, chmod, or encryption is applied, and the dumps are retained indefinitely.

Fix. Create the snapshot dir 0700 and the dump 0600 (umask 077 before the redirect), encrypt the dump with the install's age recipient, and prune old snapshots on a retention policy. The same treatment is needed for the scheduled sets written by scripts/backup.sh:30.

Verification. panel-confirmed

F23 — folderChainRefs walks parent_id with no cycle guard; a folder cycle wedges the request in an infinite loop holding a DB connection and row locks (MEDIUM, confidence medium)

Impact. A single request spins on GetFolder round-trips and grows refs without bound while holding a pooled DB connection and the row locks taken by the preceding ReparentFolderTree UPDATE, inside an open transaction. A handful of concurrent requests exhausts the connection pool and blocks all other folder writes; the loop only unwinds when the request context is cancelled (nginx cuts /api/ at proxy_read_timeout 300s), so the outage is renewable at will. Because the loop sits inside uow.Do the cycle itself is rolled back, which is what keeps this an availability issue rather than permanent corruption.

Where. go/internal/dms/app/acl.go:139 in folderChainRefs

What. The parent-chain climb that every ACL recompute depends on has no visited-set and no depth cap, while the only protection against a parent_id cycle is MoveFolder's path-prefix guard (service.go:1946) — and that guard reads the same attacker-influenceable materialized path, so a corrupted path lets a cycle be created and this loop never terminates.

Fix. Give folderChainRefs a seen map[string]bool (and a hard depth cap) that breaks the walk on a repeat, exactly as httpapi's folderChain already does, and return a kernel.Error so the corruption is surfaced rather than hung on. Independently, replace MoveFolder's path-prefix cycle guard with a recursive parent_id ancestry check taken under a row lock, so the guard cannot be defeated by a rewritten path or by a concurrent move.

Verification. panel-confirmed

F24 — OIDC bootstrap-admin grant matches on the raw email claim without requiring email_verified, unlike the account-linking path (MEDIUM, confidence medium)

Impact. Full wildcard-admin takeover of the deployment: the attacker's own freshly JIT-provisioned 'oidc' account is bound to the admin role, which short-circuits every rbac.Can() decision (rbac/adapters/pg.go:81), granting read/write over every document, user and setting.

Where. go/internal/httpapi/handlers_auth.go:340 in maybeBootstrapAdmin

What. The untrusted source is the email claim of an attacker-supplied OIDC ID token (POST /auth/oidc-login or the code-exchange path); the sink is s.grantAdmin, which binds the wildcard admin role. ProvisionFromOIDC deliberately requires claims.EmailVerified before linking by email (service.go:175) but the bootstrap grant re-uses the resulting user.Email with no such check.

Fix. Gate the bootstrap grant on the same assertion the linking path requires: thread the verified flag out of ProvisionFromOIDC (e.g. return claims.EmailVerified alongside the user, or stamp users.email_verified_at only for verified claims) and refuse to grantAdmin when the email was not asserted verified. Additionally make the grant genuinely one-shot — skip it once any user already holds the wildcard admin permission (s.authz.SubjectsWithPermission(ctx, adminPermission) is non-empty), so the mechanism cannot be replayed for the life of the deployment.

Verification. panel-confirmed

F25 — Any account with a NULL password_hash and a non-directory provider authenticates with password == email (MEDIUM, confidence medium)

Impact. Full account takeover knowing only the victim's email address. The hash-less branch also skips the per-account lockout counter and the RequireEmailVerification gate (both live in the hash != "" branch above it), so it is neither rate-limited per account nor blocked for unverified self-registrations.

Where. go/internal/auth/app/service.go:275 in VerifyPassword

What. The untrusted source is the {email,password} body of the unauthenticated POST /auth/login; the sink is this equality check, which returns an authenticated domain.User (and therefore a session) for any account whose stored password_hash is NULL, gated only on provider and the SSO-only flag — never on environment.

Fix. Delete the password==email convention from the production path — gate it behind the same boot-time flag that gates dev-login (cfg.Env == "development" && cfg.AllowDevLogin) rather than only on provider/breakGlassOnly, and treat an empty stored hash as an unconditional authentication failure otherwise. Independently, make account creation atomic: compute the argon2id hash BEFORE UpsertUserByIdentity and write the user row and its password hash inside one s.uow.Do transaction (AdminSetPassword already hashes first and has no window).

Verification. panel-confirmed

F26 — Unauthenticated POST /auth/login forces a 64 MiB argon2id allocation per request, including for unknown emails, with only a per-IP request-rate counter and no concurrency bound (MEDIUM, confidence medium)

Impact. Memory-amplification denial of service: N concurrent login attempts pin 64·N MiB. The only control is httpapi/ratelimit.go's fixed-window counter (default AuthIPPerMin=60, admin-settable up to 100000), which limits requests per minute but not simultaneous in-flight requests, so a single source can hold ~3.8 GB and a handful of sources can exhaust the host. The obscura API container in deploy/docker-compose.yml has no mem_limit (only the sidecars do), so the OOM lands on the host and can take Postgres with it.

Where. go/internal/auth/app/service.go:228 in Service.VerifyPassword

What. An unauthenticated request body (email/password from handlers_auth.go:61) reaches argon2id with m=64*1024 KiB (adapters/hash.go:30) on every attempt — the unknown-email branch deliberately runs the dummy verify so a miss costs the same as a hit — turning a ~100-byte request into a 64 MiB heap allocation held for the duration of the derivation.

Fix. Gate password hashing with a bounded semaphore (e.g. runtime.NumCPU() concurrent argon2 derivations, queue or 503 beyond it) so peak memory is O(cores × 64 MiB) rather than O(in-flight requests); additionally set a mem_limit on the API container and consider a per-connection/global concurrency cap on the unauthenticated auth routes.

Verification. panel-confirmed

F27 — Password login skips the argon2id work for locked/disabled/hash-less accounts, creating a remote account- and lockout-state oracle (MEDIUM, confidence medium)

Impact. Unauthenticated enumeration of valid account emails and disclosure of their disabled/locked state, plus identification of accounts that carry no password hash (the ones the password==email demo convention at service.go:275 admits). It also turns the lockout feature into a confirmation channel for a targeted account-DoS.

Where. go/internal/auth/app/service.go:243 in VerifyPassword

What. The unauthenticated POST /auth/login body reaches VerifyPassword, which deliberately equalises timing for an unknown email by verifying a dummy argon2id hash (service.go:227-229) but then returns early — before any hashing — when the account is disabled (234), locked (243) or has no stored hash (265+). The response-time step between "paid ~50 ms of argon2id" and "two DB round trips" is the side channel, directly contradicting the in-code claim at 238-241 that a locked account is not an oracle.

Fix. Compute the argon2id verification on every path: resolve either the stored hash or s.dummyHash first, call hasher.Verify unconditionally, and only then branch on Disabled/Locked/empty-hash to build the generic error. Keep the empty-hash branch paying the dummy verify as well.

Verification. panel-confirmed

F28 — Unvalidated folder name is concatenated into the materialized path, letting an attacker collide with another subtree and permanently detach it from ACL recomputation (MEDIUM, confidence medium)

Impact. The effective-read projections (folder_acl_read / document_acl_read) are rebuilt by walking the path prefix (recomputeSubtreeTx -> FolderIDsUnderSubtree / DocumentIDsUnderFolderSubtree). Once a victim subtree's stored paths no longer start with its real ancestor's path, every later SetFolderAccess / RemoveFolderAccess / SetFolderInherit / DetachFolderInheritedAccess / PurgeSubjectGrants on that ancestor silently skips the descendants: a revocation or a deny appears to succeed in the UI while the revoked subject keeps read/write access to every document in the sub-subfolders. The same corruption also defeats MoveFolder's path-based cycle guard (service.go:1946), which is the reachability precondition for the unbounded parent walk reported separately.

Where. go/internal/dms/app/service.go:522 in CreateFolder

What. name arrives straight from the POST /folders JSON body (handlers_dms.go:72) with only an emptiness check, and is concatenated into the materialized path that every prefix-based subtree query keys on; neither the path separator nor sibling-name uniqueness is enforced, so a caller can mint a folder whose path equals or prefixes another folder's subtree.

Fix. Reject folder names containing / (and control characters, leading/trailing whitespace, ./..) in CreateFolder and RenameFolder, and enforce sibling-name uniqueness with a partial unique index on (parent_id, name) WHERE deleted_at IS NULL plus a matching one for root folders. Better still, stop deriving subtree membership from the materialized path: FolderIDsUnderSubtree/DocumentIDsUnderFolderSubtree should use a WITH RECURSIVE walk over parent_id (as ExpandFolderSubtrees already does), so the authorization projection can never be steered by a user-chosen string.

Verification. panel-confirmed

F29 — Revoking a folder grant never recomputes the effective-read model of soft-deleted documents, leaving stale access on everything in the trash (MEDIUM, confidence medium)

Impact. A subject whose folder grant (or inherited grant) is revoked, denied, or lowered retains their previous effective access — up to Manage — on every document that was already in the trash under that folder. They can still GET the document header, download its version bytes (GET /versions/{v}/content), see it in ListTrash, and restore it; RestoreDocument performs no recompute, so the stale grant survives the restore.

Where. go/internal/dms/adapters/acl_pg.go:353 in DocumentIDsUnderFolderSubtree

What. Every folder-ACL mutation funnels through Service.recomputeSubtreeTx, which asks DocumentIDsUnderFolderSubtree for the documents to rebuild; that query filters out soft-deleted documents, so their document_acl_read rows keep the pre-revocation grants and the requireAccess middleware (which reads exactly that projection) keeps allowing the revoked subject.

Fix. Drop AND d.deleted_at IS NULL from the recompute working set (the projection should describe the row regardless of trash state), or add a second pass over soft-deleted documents in recomputeSubtreeTx; additionally recompute in Service.RestoreDocument so an un-trashed document is always re-derived from its current folder chain.

Verification. panel-confirmed

F30 — A legal hold can be released by any document Manager, including the owner it constrains, defeating the deletion block (MEDIUM, confidence medium)

Impact. The custodian of a record can lift the litigation/legal hold placed on it and then soft-delete or permanently destroy the document (DELETE /purge also only needs AccessManage, which the owner has). The legal hold is the control specifically meant to survive the custodian's wishes; retention floors are protected from exactly this actor, legal holds are not.

Where. go/internal/httpapi/handlers_retention.go:44 in ReleaseLegalHold

What. DELETE /documents/{docID}/legal-hold is gated only by requireAccess(AccessManage) (server.go:919) and the handler adds no records-authority check, while the sibling retention control on the same page explicitly refuses to let a Manager lower a floor without isRecordsAdmin (handlers_retention.go:74-83) — and a document's owner always holds Manage via the break-glass owner row.

Fix. Gate DELETE /legal-hold (and arguably PUT /legal-hold) on records.admin the way /dispose and /retention-extension are, or replicate the SetRetention pattern: allow placement at Manage but require isRecordsAdmin || isContentAdmin to release an existing hold.

Verification. panel-confirmed

F31 — PDF literal-string injection: pdfString does not escape delimiters on the UTF-16 path, so a non-ASCII signer name breaks out of the signature dictionary string (MEDIUM, confidence medium)

Impact. An authenticated requester can inject arbitrary PDF tokens into the signature dictionary of a document that Obscura signs with the in-house CA key, inside the signed /ByteRange. The practical effect is overriding the honest attestation metadata — e.g. appending a second /Reason claiming a certified PSrE identity check on what is only a self-hosted email-verified attestation — so a third party reading the signature panel in a PDF viewer sees an assurance level the product never provided. Malformed injections instead abort signing (self-inflicted failure).

Where. go/third_party/pdfsign/sign/helpers.go:47 in pdfString

What. Attacker-chosen signer name/email (esign service SignInfo.Name and the templated Reason) reach pdfString; when the text contains any non-ASCII rune the function UTF-16BE-encodes it and wraps it in parentheses with no escaping at all, so a code point whose UTF-16BE encoding contains the byte 0x29 emits a raw ) that terminates the PDF literal string early and lets the remaining bytes be parsed as PDF syntax inside the signature dictionary that Obscura then signs with the organisation's key.

Fix. On the UTF-16 branch, escape 0x28, 0x29 and 0x5C in the encoded bytes (or emit a hex string <...> instead of a literal string, which needs no delimiter escaping). Independently, validate/normalise signer name and email in the esign layer (reject control characters and cap length) before they reach SignInfo.

Verification. panel-confirmed

F32 — External-signature requests mint an anonymous external document link without the DLP allow_forward check enforced on share links (MEDIUM, confidence medium)

Impact. The organisation's no-forward data-loss-prevention policy for a sensitivity classification is bypassable by any user holding read-write access on the document: the document (raw PDF when secure preview is not required, server-rendered page images otherwise) is delivered to an attacker-chosen outside email address over an unauthenticated URL, and the act is recorded as a signature request rather than as an external share.

Where. go/internal/httpapi/handlers_esign.go:215 in RequestSignature

What. An authenticated user with read-write access on a document supplies an arbitrary external signer email in the request body; the handler builds an envelope that emails a token-gated public URL (and GetSignerInviteLink hands the raw URL back) which serves that document to an account-less outsider — yet, unlike CreateShareLink, this path never consults sharing.GetDLPPolicy(...).AllowForward, the control whose documented purpose is gating "external share-link creation".

Fix. Apply the same DLP gate CreateShareLink uses before creating any envelope that contains an external (account-less) signer, and again in GenerateExternalSignerLink/ResendExternalInvite: load the document's classification policy and reject when !AllowForward unless the caller is a content admin or holds an OverridePositions position. Consider recording external signer invites in the same egress/issuance ledger as share links.

Verification. panel-confirmed

F33 — Signature specimen PNG is validated only by magic bytes and byte length, so a decompression bomb expands to multi-GB during signing (MEDIUM, confidence medium)

Impact. A ~3.6 MB PNG declaring 30000x30000 pixels decodes to ~3.6 GB, plus ~2.7 GB of RGB and ~0.9 GB of alpha bytes, all in one request goroutine — roughly a 2000x amplification. Go's out-of-memory is a fatal runtime throw that middleware.Recoverer cannot catch, so any authenticated user (or an invited external signer) can kill the whole server process.

Where. go/internal/esign/adapters/pdfsign.go:133 in (*PdfSigner).Sign

What. info.Image is a user-supplied PNG that decodePNG (go/internal/esign/app/service.go:3691-3709) accepts on magic header + 5 MiB decoded-byte length alone — never dimensions; SignFile then hands it to createImageXObject (go/third_party/pdfsign/sign/appearance.go:71), which image.Decode's it and walks every pixel into two more in-memory buffers.

Fix. In decodePNG, run image.DecodeConfig on the decoded bytes and reject anything above a small megapixel budget (e.g. 4 MP) and any non-PNG config, before storing it or passing it to the signer; additionally reject images whose declared dimensions exceed the appearance rectangle by an unreasonable factor.

Verification. panel-confirmed

What was verified

Forty-eight researchers produced 107 candidates, 96 after deduplication. Each candidate reaching the panel faced three independent verifiers applying different lenses and survived only on a majority: 45 reviewed across 135 votes, 33 survived, 12 rejected as false positives. Thirty survivors were unanimous; F5, F22 and F31 carried a 2/3 majority, which caps their confidence at medium.

Two pairs describe one defect each, found independently through different lenses: F11/F20 (audit-chain algorithm selected from an unauthenticated column) and F13/F14 (declassification guard using a hardcoded rank table). One fix resolves each pair.

Scans are nondeterministic; running them regularly builds coverage over time. This complements SAST, dependency scanning, and code review — it does not replace them.