think
16px
820px

Security audit — consolidated findings (2026-07-29)

Scope: whole-repo adversarial read-only audit, 5 specialist agents, at HEAD aa16247+
(current HEAD 3dd4d46). No files were modified and no tests were run by the auditors.
This document deduplicates and severity-ranks their verified findings.

Status: 4 of 5 specialists complete (MCP+AI, deploy/secrets, ACL/IDOR, preview/office/blob).
The 5th (workflow object authz) is still running; its surface is already largely covered by the
ACL/IDOR agent's F1/F6/F7–F10 below — this file will be appended if it surfaces anything new.

Not committed. This file enumerates live-exploitable holes and secret-file paths. Decide
whether it belongs in git before committing it.


0. Decisions only you can make (not a code patch)

# Item Why it's yours Action
D1 Rotate the live secrets exposed during dev deploy/mekari.env is world-readable (-rw-rw-r--) and a stale deploy/mekari.env.bak-premekaridrop blob is still in the local git reflog. Real values: OpenAI key, PERURI_PASSWORD, ONLYOFFICE_JWT_SECRET, SIGNIT/MEKARI secret, AUDIT_CHAIN_KEY. The .bak header itself says "rotate after testing." Rotate at each provider; then delete the .bak, git reflog expire --expire=now --all && git gc --prune=now, chmod 600 the env files. I did not touch these — tightening perms blindly breaks the running demo (the blob_age.key at 644 is deliberate so the container uid 65532 can read it).
D2 Confirm prod isn't running the hardcoded DB/MinIO passwords deploy/docker-compose.yml hardcodes Postgres obscura:obscura and MinIO obscura/obscura-dev-secret as literals with no ${VAR}; the prod overlay overrides only ports + OBSCURA_ENV. If dms.val.id runs off that base, its DB password is literally obscura. Verify prod's actual overlay/env; rotate prod creds if affected (delicate — your call).
D3 Verify valbox nginx resets X-Real-IP The backend trusts X-Real-IP as authoritative for the login/OTP brute-force limiter and the signing audit trail. The container nginx doesn't reset it (see C-M8); prod's real TLS edge is valbox nginx, not in this repo, so the auditor couldn't check it. If valbox doesn't set/clear it, spoofable per-request → bypasses IP rate limits + forges signing-evidence IPs.
D4 Demo password == email + hardcoded director@obscura.local admin The demo is effectively open-admin (documented convention I use for e2e), but the password==email branch and the boot-time admin grant live in the production binary. Decide whether to keep for the demo; confirm prod has no local director@obscura.local account.
D5 OnlyOffice JWT_ENABLED=true + empty JWT_SECRET (unverified) If the stock documentserver:8.3 treats an empty secret as "validate against empty key" rather than "disabled," the /coauthoring/ + /converter/ paths proxied to the public origin become forgeable. 10-minute test against the image.

1. Code / config fixes — ranked

CRITICAL

id finding file fix shape
C1 Hardcoded DB + object-store creds, identical on every deploy (see D2) deploy/docker-compose.yml:9-11,24-25,207,218-219 ${POSTGRES_PASSWORD:?} etc.; generate in bootstrap-client.sh; add to requirements.go as security.
C2 Forgeable "Trusted" signature badge — trust computed against attacker-orderable Certificates[0], not the IssuerAndSerialNumber-resolved signer; /ByteRange never validated (shadow-update forgery). Green "Intact + Trusted" with any signer/date. go/internal/esign/adapters/pdfsign.go:305-329 (+ ByteRange) Resolve signer cert from SignerInfo; validate ByteRange covers the whole file. Release-blocking for prod. Needs careful, tested patch.

HIGH

id finding file fix shape
H1 Any member can rewrite/archive/delete ANY workflow template → approval-chain hijack. Owner guard is dead code: templates get Kind='', OwnerID=NULL; routes gated only on workflow.define (member perm). Per "everything is a template," this is every saved chain incl. the seeded org letter chain. go/internal/workflow/app/service.go:748-756,802,2127,2152; adapters/pg.go:702-704 Set OwnerID on templates and gate owner-or-admin unconditionally (drop the Kind==Custom && clause).
H2 Bulk move has no destination-folder gate → Contributor→Manager escalation. Sources ACL-checked, newFolderID not; single-doc twin does check it. go/internal/dms/app/service.go:2356-2376 Apply EffectiveFolderAccess ≥ ReadWrite on dest; raise source tier to Editor for parity.
H3 Live provider creds in local git object store + world-readable on disk (see D1) reflog blob 61c41571…; deploy/mekari.env, deploy/secrets/blob_age.key rotate + gc + chmod.
H4 bootstrap-client.sh makes the blob-encryption master key world-readable (chmod 644), license 666. deploy/bootstrap-client.sh:120-122 chown 65532:65532 + 400/600; read OpenAI key from stdin not argv.
H5 ufw does not filter Docker-published ports — the documented safety net is illusory; DNAT/DOCKER chain bypasses INPUT. The !override in the prod overlay is the only control (and just demonstrated it can be incomplete — OnlyOffice 0.0.0.0:38090, fixed today in b161e38). docs/DEPLOYMENT.md:57-62,242 DOCKER-USER DROP rules or publish only 127.0.0.1:8091; CI check that every base ports: has a prod !override.
H6 3 unauth root Python sidecars process untrusted docs with no size/mem/cpu caps; FastAPI /docs enabled; openpyxl w/o defusedxml (billion-laughs); stego forks cpu_count()-1 per request. Internet-reachable on any base-compose host. deploy/extract-sidecar/app.py, embed-sidecar, stego/.../server.py shared-secret header, USER nobody, mem_limit/cpus/read_only/cap_drop/no-new-privileges, docs_url=None, body cap, decompression-ratio guard, add defusedxml.
H7 OnlyOffice save-callback forgeable + no save-time ACL re-check — callback "proof" accepts any secret-signed JWT (every editor user, incl. read-only, holds one); document save path (unlike letters/letterheads) never re-checks ACL → write-after-revocation for 12h. go/internal/httpapi/handlers_office.go:593-601,697-731 Bind the doc-server JWT to body.Key (+ mandatory exp); add EffectiveDocumentAccess ≥ ReadWrite in saveOfficeEdit.
H8 Password change / "sign out everywhere" don't revoke mobile refresh tokens, OAuth tokens, or API keys. auth service Revoke all session/refresh/oauth on credential change.
H9 Mobile: Android release permits cleartext HTTP; biometric app-lock fails open with no enrolled biometric. mobile/ usesCleartextTraffic=false; fail-closed lock.

MEDIUM

id finding file fix shape
M1 AI base_url SSRF + key exfil (my session code e3afcfd) — admin sets base_url → real key ships to their host via /test. Only TrimSpace. go/internal/ai/app/provider.go:232-271 Validate scheme/host, block private IPs, no-redirect client. In my code — first in the batch.
M2 AI daily budget bypassable (global TOCTOU, recorded-after with err ignored); embeddings unmetered + unthrottled. go/internal/ai/app/service.go:161-184 Atomic reserve-before; meter embeddings.
M3 MCP API keys carry the user's full REST authority — scopes recorded, never enforced; an obsk_ key POST /documents/deletes/downloads, defeating the module's "text-only, DLP-safe" claim. (OAuth path does it right with MCPOnly.) go/internal/httpapi/handlers_admin.go:399-402 Mint MCP keys MCPOnly, or enforce scopes in rbac.Can.
M4 Any member can write attributes/view/order on ANY folderdefault_classification write silently downgrades DLP class of docs later created there. server.go:757 (+ handlers_dms_content.go:244) Add requireFolderAccess(Editor).
M5 Copy + Finalize-as-new bypass destination-folder gate — plant content into a no-access folder. handlers_dms.go:230-261, handlers_finalize.go:136-150 Add dest EffectiveFolderAccess check.
M6 /workflows/{id}/act collapses a definition chain (no inverse guard) — step-1 approver jumps a 5-step letter chain straight to approved; seal/number/send skipped. go/internal/workflow/app/service.go:648-664 Refuse definition-driven instances in Act (mirror ActDefinition).
M7 stego demo master key passes preflight green — committed 0b0b…0b default satisfies hexKey (valid hex ≥32B); plaintext gRPC metadata. docker-compose.yml:202, requirements.go:180-186 knownWeakKeys reject-set; ${STEGO_MASTER_KEY:?}; TLS or file-mount the key.
M8 Container nginx doesn't reset X-Real-IP (see D3) web/nginx.conf:60-76 proxy_set_header X-Real-IP "" + X-Forwarded-For $remote_addr in /api/+/l/.
M9 security-severity settings don't block boot — only COMPLIANCE_STRICT (off by default, covers 2 vars); update.sh/bare compose bypass the gate. requirements.go:329-336, wire.go:107 Make BlocksDeploy fatal at boot when Env==production, single loud escape hatch.
M10 No CSP / X-Frame-Options / nosniff on either nginx tier — preview + share pages framable (clickjacking on download/mark-sent), XSS unrestricted exfil, sniffing on user-controlled bytes. web/nginx.conf, deploy/nginx/obscura.conf.example Add header block (frame-ancestors 'none', CSP, nosniff, Referrer/Permissions-Policy).
M11 gotenberg SSRF denylist bypassable via integer/octal/hex/IPv4-mapped-IPv6 IP encodings; authed HTML author reads internal surfaces into the PDF. docker-compose.yml:48-49 Switch to --chromium-allow-list; put gotenberg on internal: true network.
M12 229 world-readable pg_dumps (1.9 GB) in deploy/pre-update/; backup dir root-owned world-readable. deploy/update.sh:107 retention prune + umask 077; move SNAPSHOT_DIR out of tree; chmod 700.
M13 Demo OBSCURA_ENV=development → session cookie without Secure on an HTTPS host. handlers_auth.go setSessionCookie Drive Secure off https:// base URL, not Env.
M14 WritableFolderIDs missing deny legSuggestFolders/suggest-folder leaks a denied folder's id/path/similarity (write still refused). go/internal/dms/adapters/acl_pg.go:401-419 Add AND access_mode ≥ 2 deny-aware leg.
M15 SSE error event forwards upstream body + internal host to authed users; embeddings adapter has no 401 scrub; scrubKey is exact-substring (masked key slips through). handlers_ai.go:603, embed_openai.go:64 Generic error to client; scrub embeddings adapter; broaden scrub.

LOW (batch as cleanup)

  • OfficeContent token = 12h non-revocable unaudited bearer; office live-key registry poisoning → cross-doc forcesave trigger/liveness oracle (handlers_office.go:483-528,608-613).
  • Preview sid not principal-bound (replayable 15 min — by design, minter-attributed).
  • Folder metadata readable w/o folder access; overdue-tasks list unscoped; subworkflow graft onto another's instance; return accepts any pending task kind; workflow graph leak; view-only image share egresses unmarked (DLP is PDF-only); SubmitSignOTP existence probe on sequential TERA job ids.
  • MCP kill switch fails open on missing row + doesn't cover the OAuth AS; preview_document evicts other users' live preview sessions.
  • /readyz public returns raw dependency error strings; curl|sudo sh in the runbook; latest tags in the default stack; provider secrets as env vars (not _FILE); /verify + /ai/summarize//classify/KTP-OCR unbounded upload; registration enumeration oracle; TOTP not enforced for non-local providers; plaintext TOTP secrets.

2. Verified CLEAN (confidence)

  • No cross-object read IDOR. All blob addressing resolves the hash server-side (4 blobs.Get sites); no handler takes a hash/blob key from the client. Avatar path returns only avatar blobs.
  • DENY-WINS / DetachInheritedAccess HOLDS — owner > deny > max-grant in both resolvers and both listing predicates; detach splits denies before merge.
  • Office token crypto sound — HS256 hard-coded (no alg confusion), constant-time compare, exp/purpose/sub enforced; editor-config JWT not replayable as a content/callback token.
  • Child-id-under-parent binding clean across attachments, comments, links, versions, envelopes/signers, share logs.
  • Listings are SQL-ACL-filtered, not UI-filtered. Notification fan-out ACL-filtered per recipient.
  • No committed secrets in reachable git history; .gitignore/.dockerignore correct; no docker.sock mount / privileged / host-net / cap_add; no eval/pickle/shell=True; PKCE-S256 OAuth solid; licensing trust anchor sound; update.sh rollback sound (a failed deploy can't leave auth disabled); /auth/dev-login correctly double-gated and OFF on the demo.
  • Single-tenant product — no org_id column; multi-tenant isolation N/A.

3. Unverified (needs a short check)

  • Does documentserver:8.3 sign its /cache/files/… URLs? Decides whether H7's callback weaknesses escalate to a cross-document read.
  • OnlyOffice empty-JWT_SECRET behavior (D5).
  • Whether the internet-facing demo host actually publishes the base compose's 0.0.0.0 ports.
  • python-docx/python-pptx resolve_entities=False at pinned versions (openpyxl exposure stands regardless).