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 folder — default_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 leg — SuggestFolders/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
sidnot 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;
returnaccepts any pending task kind; workflow graph leak; view-only image share egresses unmarked (DLP is PDF-only);SubmitSignOTPexistence probe on sequential TERA job ids. - MCP kill switch fails open on missing row + doesn't cover the OAuth AS;
preview_documentevicts other users' live preview sessions. /readyzpublic returns raw dependency error strings;curl|sudo shin the runbook;latesttags 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.Getsites); 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/subenforced; 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/.dockerignorecorrect; no docker.sock mount / privileged / host-net / cap_add; noeval/pickle/shell=True; PKCE-S256 OAuth solid; licensing trust anchor sound;update.shrollback sound (a failed deploy can't leave auth disabled);/auth/dev-logincorrectly double-gated and OFF on the demo. - Single-tenant product — no
org_idcolumn; multi-tenant isolation N/A.
3. Unverified (needs a short check)
- Does
documentserver:8.3sign its/cache/files/…URLs? Decides whether H7's callback weaknesses escalate to a cross-document read. - OnlyOffice empty-
JWT_SECRETbehavior (D5). - Whether the internet-facing demo host actually publishes the base compose's
0.0.0.0ports. python-docx/python-pptxresolve_entities=Falseat pinned versions (openpyxl exposure stands regardless).