think
16px
820px

Claude Security results

Scanned the auth/authz spine of ahu-ocr-tidyup at /home/efran/remote-development/poc-ahu-ai/ahu-ocr-tidyup/.claude/worktrees/role-separation, branch feat/role-separation, revision 6032e5f8, clean tree, on 2026-07-27 (UTC). Mode: scoped codebase scan at high effort over 252 tracked files — backend/src/security, backend/src/routes, backend/src/middleware, frontend/src/lib, frontend/src/components/role-guard.tsx, frontend/src/pages/SsoLandingPage.tsx, frontend/src/pages/LoginPage.tsx — focused on production code rather than tests and fixtures. 17 findings survived verification: 5 HIGH and 12 MEDIUM. Nothing in this report depends on SECURITY_ENFORCE being left at its shadow default: every finding was judged as if the flag were on, and each one is either exploitable today regardless of the flag or stays exploitable once it is flipped.

Coverage

Thirteen components were reviewed: the security spine (route-policies.ts, authenticate.ts, index.ts, verifikator-guard.ts); object scope and ownership (object-scope.ts, domains.ts, owner.ts, persona-map.ts); the local HS256 session verifier; the Keycloak handoff verifier; the two token-issuance routes in auth.ts; the shared /api/submissions and /api/klasifikasi mounts plus documents.ts; the PP, Apostille and PT flow routes; the verifikator and admin routes; the middleware/audit layer (request context, rate limiting, CORS, submission lock, upload guard); the public and utility routes; and the frontend auth surface. Fifteen researchers ran across those components — the authorization, injection and crypto/secrets lenses, two per cell on the core areas — plus a cross-component sweep and a dedicated secrets pass. The memory-and-unsafe lens was pruned as inapplicable to TypeScript.

Two areas were deliberately not audited. Test suites and fixtures (backend/src/**/__tests__, frontend/src/lib/__tests__ and the *.test.ts files) were read as background to understand production behaviour rather than treated as targets — the exception is the secrets pass, which did examine them for committed keys and found none. Presentation-only frontend lib modules (labelling, formatting, currency, country lists, review type definitions) were skipped as having no network calls, credential handling or authorization logic. Because this was a scoped scan, the whole-tree completeness check does not apply; the scope's own file list was covered.

Thirty-one deduplicated candidates came out of research. Eighteen were put to the panel and 13 were left unreviewed by the verification cap — lower-ranked candidates, chiefly defence-in-depth observations (the request logger writing vote tokens and SSO handoff tokens into stdout, the spoofable X-Forwarded-For recorded as the audit client IP, the empty-CORS-allowlist origin reflection, unbounded list limit parameters, the Content-Disposition filename built from a client-controlled extension, the optional-exp handling in both token verifiers, the null-ownerId allow in checkSubmissionAccess, the unanchored substring match granting staff personas in persona-map.ts, and the unvalidated returnUrl/originTransactionRef claims on the SSO exchange). They are not reported here because they were not verified — treat them as unexamined, not as clean.

Nothing in this scan executed the repository's code. No tests were run, no request was sent, no exploit was fired; every finding is derived from reading the source, including the vendored Hono router and zod under backend/node_modules.

One candidate was rejected by the panel and is deliberately absent: the missing ownership check on /api/perbaikan/:submissionId. It is a real structural gap, but two verifiers established that no attacker can instantiate it today — the mount admits only notaris and staff, every notaris token shares the single subject dev:notaris, and staff bypass ownership by design — so it is a latent gap that becomes live only when a per-user notaris issuer exists.

Findings

F1 — Percent-encoded mount name skips both the route policy and the per-object scope check (HIGH, confidence high)

Impact. Every mount-level authorization decision can be skipped by encoding one character of the mount name. With SECURITY_ENFORCE=on an unauthenticated caller reaches /api/verifikator (approve, reject and assign, none of which carry an in-handler guard), /api/admin/settings, /api/submissions and /api/klasifikasi — and the per-object domain and ownership check on the shared submissions mount is disabled by the same request.

Where. backend/src/security/route-policies.ts:170 in authorize (second site: backend/src/security/object-scope.ts:99 in scopeSubmissionObjects)

What. authorize derives the policy key from new URL(c.req.url).pathname, which never percent-decodes, while Hono routes on getPath()'s output, which applies decodeURI whenever the path contains %. decodeURI decodes unreserved escapes — %76 becomes v. The two layers therefore read different strings: policyForPath matches no mount, returns undefined, and the guard at line 172 skips authentication and role checking entirely, while the router still dispatches to the real handler. scopeSubmissionObjects re-derives the path the same way, so submissionIdFromPath returns null and its ownership check never runs.

Exploit scenario. An unauthenticated attacker sends POST /api/%76erifikator/submissions/<id>/decide with {"decision":"REJECT","notes":"x","verifikatorId":"someone.else"}. Hono decodes %76 to v and dispatches to routes/verifikator.ts:112; authorize looked up the literal /api/%76erifikator/..., matched no policy, and passed the request through with no token. The PT filing is terminally rejected. The same shape reaches GET /api/%73ubmissions/<victim-id>/review, where the domain and ownership checks are skipped for the same reason.

Preconditions.
- Ability to send a raw HTTP request path — no authentication, no user interaction.
- Holds with SECURITY_ENFORCE=on. The deployed nginx uses a URI-less proxy_pass, which forwards the encoded target unchanged.

Fix. Derive the path from the same value Hono routes on (c.req.path) in route-policies.ts, object-scope.ts and verifikator-guard.ts, and make policyForPath fail closed: an unmatched /api/* path should deny rather than return "no policy".

Verification. 3/3 lens verifiers confirmed.

F2 — /api/documents lets any persona enumerate and download every user's uploaded KTP scans and deeds (HIGH, confidence high)

Impact. Any authenticated user of any persona — including a self-service Apostille or PP applicant — can list every document id in the system and download the raw file for each: KTP scans with NIK, address, date of birth and photograph, NPWP cards, notarial deeds, Apostille source documents, across all three product areas.

Where. backend/src/routes/documents.ts:528 in the GET /:id/pdf handler

What. ROUTE_POLICIES maps /api/documents to AUTHED (route-policies.ts:90), which sets no roles, so authorize's role branch never fires. The per-object scope middleware is installed only for /api/submissions and /api/klasifikasi (security/index.ts:82), and routes/documents.ts contains no ownership, submission-membership or domain check on any of its routes. GET / builds its where-clause from status/type/search only, with an uncapped caller-supplied limit; GET /:id returns the full extraction including ktpExtraction; GET /:id/pdf streams the file after a bare findUnique on the path id. Document.ownerId exists in the schema but is never written and never read.

Exploit scenario. An applicant obtains a session token, calls GET /api/documents?type=KTP&limit=100000 and receives every document id in the database, then calls GET /api/documents/<id>/pdf for each and downloads other people's identity documents. The same token can PATCH /api/documents/<id>/fields to rewrite extracted values on another user's document, with no audit event and no status gate.

Preconditions.
- Any valid session token, of any persona.
- Exploitable with SECURITY_ENFORCE=onAUTHED admits every role by design.

Fix. Give /api/documents per-object authorization: resolve documentId to its SubmissionDocument and Submission and run checkSubmissionAccess before serving or mutating; scope the list with listOwnerScope plus a domain filter; cap the page size.

Verification. 3/3 lens verifiers confirmed.

F3 — DELETE /api/settings/database destroys all data and is reachable by the lowest-privilege persona (HIGH, confidence high)

Impact. One request from any authenticated account — including a self-service applicant — deletes every Submission and Document row, cascading into every child table including the KTP, passport and NPWP extraction tables, and unlinks every uploaded file on disk. There is no soft delete and no backup mechanism anywhere in the repository.

Where. backend/src/routes/settings.ts:24 in the DELETE /database handler

What. The handler runs db.submission.deleteMany() and db.document.deleteMany() unconditionally, then sweeps the uploads directory. It calls no guard: no requireAdminRole, no role check, no environment check, no confirmation body. The mount policy is "/api/settings": AUTHED, which restricts nothing beyond authentication, and submissionLockMiddleware returns early because the path contains no UUID. The comparable admin route at admin.ts:38 does call requireAdminRole, so the pattern exists and was simply not applied here.

Exploit scenario. An umum or perseroan applicant obtains a session the normal way and sends DELETE /api/settings/database with that bearer token. The AUTHED mount check passes, no in-handler guard exists, and the database and the uploads directory are erased.

Preconditions.
- Any valid session token, of any persona.
- Exploitable with SECURITY_ENFORCE=on.
- The append-only audit trigger aborts the statement only when a VerifikasiAuditEvent row already exists; on a PP-only or pre-verification instance the wipe completes in full.

Fix. Move the destructive endpoints under the ADMIN policy and add requireAdminRole as the first statement of the handler. Better still, keep the database wipe behind an explicit development-only configuration flag so it cannot ship enabled.

Verification. 3/3 lens verifiers confirmed.

F4 — PATCH /api/documents/:id/people rewrites any director or shareholder row by a body-supplied recordId (HIGH, confidence high)

Impact. Any authenticated persona can rewrite the identity and shareholding data — nik, namaLengkap, jabatan, alamat, npwp, jumlahSaham, persentase — of any director, commissioner, shareholder or appearer row in the database, belonging to any other user's filing in any product area. The same write force-sets notarisConfirmed with a value snapshot, forging the notary's attestation on the tampered data.

Where. backend/src/routes/documents.ts:377 in the PATCH /:id/people handler

What. The handler reads table, recordId and updates from the request body (validated only as z.record(z.string(), z.unknown())), and both the preceding findUnique and the update key on where: { id: recordId } alone. The path :id is used only to assert that some document exists; it never constrains recordId, and these row ids are standalone primary keys independent of documentId. /api/documents is AUTHED with no object scoping, so nothing else checks the caller. Because recordId travels in the body, the terminal-submission lock — which scans UUIDs in the path only — does not see it either, so rows in a frozen submission remain writable.

Exploit scenario. The attacker calls GET /api/documents to enumerate documents, GET /api/documents/<victim-doc-id> to read the aktaPemegangSaham row ids, then PATCH /api/documents/<their-own-doc-id>/people with {"table":"pemegangSaham","recordId":"<victim row id>","updates":{"jumlahSaham":1}}. The victim's shareholder row is rewritten and marked notaris-confirmed; no audit event is written anywhere in documents.ts, and the forged confirmation then clears the UNCONFIRMED_FIELDS finalize gate.

Preconditions.
- Any valid session token, of any persona.
- A target row id, obtainable from the same unprotected /api/documents mount.
- Exploitable with SECURITY_ENFORCE=on.

Fix. Constrain the update to the path document — require documentId to equal the path :id in both the lookup and the update where-clause — and run checkSubmissionAccess for the owning submission before writing. A handler must not set notarisConfirmed on a row it has not proven the caller owns.

Verification. 3/3 lens verifiers confirmed.

F5 — Row ownership on three mounts is decided by the client-supplied X-Notaris-User-Id header (HIGH, confidence high)

Impact. Any caller can impersonate an arbitrary notaris and list, read, edit, regenerate the official PDF/DOCX of, and soft-delete that notaris' Surat Keterangan letters and SP Pendirian PP drafts — documents carrying founder NIK, NPWP and address data plus the frozen SABH registry snapshot whose correction rows feed a real filing. Flipping the enforcement flag does not help, because the header is never compared against the session.

Where. backend/src/routes/surat-keterangan.ts:25 in resolveNotarisUserId (identical helper at backend/src/routes/sp-pendirian-pp.ts:25 and backend/src/routes/notaris-pt-list.ts:7)

What. resolveNotarisUserId returns c.req.header("X-Notaris-User-Id") ?? "anonymous", and that value is the only thing compared against the row's notarisUserId in every 403 check, in the list filter, and as the value stamped at create. The verified principal is never consulted and security/owner.ts is not imported. No middleware sets or strips the header, and the deployed nginx forwards it untouched. The frontend never sends it, so every row created through the UI is stamped "anonymous" — which a header-less request then matches.

Exploit scenario. An authenticated applicant sends GET /api/surat-keterangan with no X-Notaris-User-Id header and receives every letter filed under the "anonymous" owner, each with its id. They then PATCH /api/surat-keterangan/<id> to alter the correction values of a PT they have nothing to do with, POST /<id>/generate to render the tampered official letter, and GET /<id>/pdf to download it — or DELETE it outright.

Preconditions.
- Ability to set an arbitrary HTTP request header.
- With SECURITY_ENFORCE=on, any valid session token: /api/surat-keterangan is AUTHED, so it does not even require the notaris persona.

Fix. Delete resolveNotarisUserId in all three files and derive the actor from the verified principal (requestOwnerId / getPrincipal(c).sub), failing closed for an anonymous principal. Keep the row-level comparisons but source the actor only from the session, and tighten /api/surat-keterangan from AUTHED to the PT flow roles.

Verification. 3/3 lens verifiers confirmed.

F6 — The PT verification decision endpoint has no domain guard, and records an actor supplied in the request body (MEDIUM, confidence high)

Impact. A verifikator_apostille account — entitled to the APOSTILLE domain only — can APPROVE, REJECT or REVISE any PT corporate-change filing. REJECT writes DITOLAK, which no route reverses; APPROVE dispatches real shareholder voting invitation emails; REVISE wipes every section approval, blanks attestations and clears FAIL overrides. The decision row and its log name whatever verifikatorId the request body supplies.

Where. backend/src/routes/verifikator.ts:112 in the POST /submissions/:submissionId/decide handler

What. The /api/verifikator mount policy admits verifikator, verifikator_pt, verifikator_apostille and admin alike, on the stated assumption that per-domain actions call requireVerifikatorRole. routes/verifikator.ts never imports that guard and has no router-level .use(), although both comparable decision endpoints do call it (apostille.ts:837 with domain "apostille", perbaikan-verifikasi.ts:26 with domain "pt"). The handler's only pre-write checks are on verification status, never on submission type or the caller's domain. The assign route at line 449 has the same gap and takes movedBy from the body.

Exploit scenario. A verifikator_apostille account calls GET /api/verifikator/submissions — whose domain filter is a client-supplied query parameter, see F7 — to obtain a PT submissionId, then POSTs /api/verifikator/submissions/<id>/decide with {"decision":"REJECT","notes":"x","verifikatorId":"budi.pt"}. The mount policy admits them, no domain guard runs, the PT filing is terminally rejected and the decision log names a colleague.

Preconditions.
- An authenticated verifikator_apostille or legacy verifikator session token.
- Exploitable with SECURITY_ENFORCE=on.

Fix. Call requireVerifikatorRole(c, "perubahan-decide", "pt") as the first statement of the decide handler, and apply the same guard to run-advisory and assign. Take the recorded actor from the verified principal instead of body.verifikatorId; the same body-supplied-actor pattern also needs fixing at apostille.ts:905 and perbaikan-verifikasi.ts:65.

Verification. 3/3 lens verifiers confirmed.

F7 — The verifikator inbox's PT/Apostille split is enforced by a client-supplied query parameter (MEDIUM, confidence high)

Impact. Either verifikator team can read the other team's entire queue by omitting or changing one query parameter: Apostille applicants' full names, service type and SLA state on one side; PT company names, change types, AI risk recommendations and voting tallies on the other. The submission ids it returns are the input to the ungated cross-domain decision endpoint in F6.

Where. backend/src/routes/verifikator-queue.ts:381 in the GET /api/verifikator/submissions handler

What. The handler runs all three database queries unscoped, merges the rows in memory, and only then filters on c.req.query("domain"). If the parameter is absent or holds any other value, neither branch fires and the merged inbox is returned in full. The caller's role is never read in the file — no getPrincipal, no requireVerifikatorRole, no use of typesForRole or DOMAINS_BY_ROLE — and the mount policy admits both teams, so the separation domains.ts declares exists only in the browser, where the value comes from a localStorage-backed role atom. The repository's own test asserts that a request with no domain parameter sees both domains.

Exploit scenario. A verifikator_pt account issues GET /api/verifikator/submissions?status=all&limit=50 with no domain parameter, or with domain=apostille, and receives the full Apostille applicant queue including every applicant's name — despite DOMAINS_BY_ROLE granting that persona PT only.

Preconditions.
- An authenticated verifikator_pt or verifikator_apostille session token.
- Exploitable with SECURITY_ENFORCE=on.

Fix. Derive the domain set from getPrincipal(c).role via DOMAINS_BY_ROLE/typesForRole and intersect it with the requested domain parameter, so the client value can only narrow what the role already permits, never widen it. Apply the same principal-derived scoping to the per-submission verifikasi read on the same mount.

Verification. 3/3 lens verifiers confirmed.

F8 — Apostille per-submission routes check type and status but never ownership, and the document delete is keyed on documentId alone (MEDIUM, confidence high)

Impact. An Apostille applicant can destroy another applicant's uploaded documents — the SignerMatch, the Document row with its cascading extracted fields, and the file on disk, which is the only copy — and can set signers, add documents, reclassify, force phase-2 extraction and submit on another applicant's request. No audit event records any of it.

Where. backend/src/routes/apostille.ts:606 in the DELETE /:id/documents/:documentId handler

What. Every /:id handler on this mount loads the submission with select: { type: true, status: true } and validates only that the type is APOSTILLE plus a status window; ownerId is never selected or compared, and no handler calls getPrincipal or checkSubmissionAccess. /api/apostille is not one of the two mounts scopeSubmissionObjects covers, and APOSTILLE_FLOW admits the non-staff umum persona. In the delete handler the destructive statements key on documentId alone with no binding to the path submission, so the attacker can pass their own submission id together with any victim document id. The file does stamp ownerId at create and does scope its list, so the data for a check exists and is simply not consulted.

Exploit scenario. An umum applicant harvests document ids from the unscoped GET /api/documents listing, then issues DELETE /api/apostille/<their-own-submission-id>/documents/<victim-document-id>. The type and status checks pass against their own row, and the victim's document row, its signer match and its file on disk are destroyed.

Preconditions.
- An APOSTILLE_FLOW session; the umum applicant persona suffices.
- A victim document id, enumerable from /api/documents or /api/analytics/submissions.
- Exploitable with SECURITY_ENFORCE=on; per-applicant ownership is meaningful once the SSO issuer is in use, which mints a per-user subject.

Fix. Select ownerId in these lookups and run checkSubmissionAccess — or extend scopeSubmissionObjects to the /api/apostille mount — before any read or mutation keyed on :id, and bind the document delete to the path submission via SubmissionDocument rather than deleting by documentId alone.

Verification. 3/3 lens verifiers confirmed.

F9 — The SSO exchange lets the request body choose the verifikator domain (MEDIUM, confidence high)

Impact. A staff user of another module (PP or SABH) holding a valid portal token obtains the verifikator_apostille persona by adding one field to the exchange request. That persona reads every Apostille applicant's queue and personal documents and issues binding approve/reject decisions — the exact boundary the PT/Apostille split exists to enforce. One holder can mint one session per domain.

Where. backend/src/routes/auth.ts:150 in exchangeSsoToken (the OR that does it: backend/src/security/persona-map.ts:59)

What. The minted persona is computed from the verified token's roles and module codes plus originSystem — and originSystem comes from the untrusted POST body. resolveOriginSystem returns the caller's value verbatim whenever it matches one of three literals, consulting the token's module_code only as a fallback. In persona-map.ts the domain is an OR: looksApostille(roles, moduleCodes) || originSystem === "APOSTILLE_REBUILD", and the staff branch returns verifikator_apostille whenever that is true. Nothing downstream re-derives the domain from verified claims; the persona is signed into our own session and every later check reads only that string. The repository's own tests pin this behaviour with an empty moduleCodes array.

Exploit scenario. A PP staff user with the role ptp-verifikator follows the normal handoff, then replays it as POST /api/auth/sso {"handoffToken":"<their real token>","originSystem":"APOSTILLE_REBUILD"} — or simply edits the landing URL, since the SSO landing page forwards ?originSystem= into the body. The response carries a session JWT with role verifikator_apostille, which satisfies APOSTILLE_FLOW and requireVerifikatorRole for the apostille domain.

Preconditions.
- The attacker holds a valid Keycloak handoff token whose roles match a staff substring.
- KEYCLOAK_URL and KEYCLOAK_REALM configured so the exchange is live. This path is dormant in the shipped defaults and is activated by setting the two variables the feature was written for.

Fix. Derive the domain only from verified claims. Honour the body's originSystem solely to disambiguate a genuinely ambiguous token, and reject it when it contradicts the token's module_code entitlement; never let a request field widen the staff domain.

Verification. 3/3 lens verifiers confirmed.

F10 — An unvalidated staging fileId is concatenated into a filesystem path in three handlers (MEDIUM, confidence high)

Impact. Any authenticated persona can delete arbitrary JSON files outside the staging directory — the backend's package.json or tsconfig.json, for instance — breaking the service, and can use the create path as a filesystem existence oracle for any .json path on the host.

Where. backend/src/routes/submissions.ts:331 in the POST / handler (same pattern at backend/src/routes/klasifikasi.ts:515 and backend/src/routes/klasifikasi.ts:723)

What. On POST /api/submissions the body is validated with z.array(z.custom<CreateFileInput>()).min(1); in this repository's zod, custom() without a validator becomes an always-true predicate, so files[].fileId is an unchecked attacker string interpolated into path.join(TEMP_DIR, + "${fileId}.json" + ) and then read, copied and unlinked. No percent-encoding is needed — ../../ travels straight from JSON into path.join. The klasifikasi routes take the same value from the URL, where Hono's routing preserves %2F through decodeURI and then decodes it in getDecodedParam, so ..%2F..%2Fname arrives as ../../name. There is no containment check on the resolved path anywhere.

Exploit scenario. DELETE /api/klasifikasi/file/..%2F..%2Fpackage resolves to <backend>/package.json, which exists and parses as JSON, so fs.unlinkSync removes it and the endpoint answers {"deleted":true}. On the create path, POST /api/submissions with {"files":[{"fileId":"../../package",...}]} returns 500 when the target exists and parses and 409 when it does not, mapping the filesystem.

Preconditions.
- Any valid session token — both mounts are ANY_FLOW, so every persona qualifies.
- Exploitable with SECURITY_ENFORCE=on.
- The escalation to arbitrary file read via copyFileSync was examined and is not achievable: the magic-byte upload guard prevents planting a JSON meta file, and a usable meta needs both originalName and storedPath.

Fix. Validate fileId as a UUID before building any path — the id is server-minted with crypto.randomUUID — replace z.custom with a real object schema for the create body, and assert path.resolve(metaPath).startsWith(TEMP_DIR + path.sep) before touching the filesystem, including for the storedPath read out of the meta file.

Verification. 3/3 lens verifiers confirmed.

F11 — Field confirm/unconfirm writes a row identified only by a body-supplied entityId (MEDIUM, confidence high)

Impact. A caller can set or clear notarisConfirmed, and write the value snapshot, on director, commissioner, shareholder, appearer, founder, KBLI and akta-perubahan rows belonging to another user's filing in another product area — from inside the one mount the per-object guard is supposed to protect. Forging the flag pushes unattested OCR output past the finalize gate; clearing it silently regresses a completed review. The audit event is written against the attacker's own submission, so the victim's trail shows nothing.

Where. backend/src/routes/submissions.ts:1493 in the POST /:id/documents/:docId/fields/confirm handler

What. resolveFieldRef turns {entityType, entityId} from the request body into {kind, rowId} carrying only the body-supplied row id, discarding the path documentId for seven of the entity kinds. confirmWithin/unconfirmWithin then read and write by primary key alone. Every guard on the route validates only path objects the attacker owns: the status check and the SubmissionDocument membership check both concern the path submission and document, and scopeSubmissionObjects resolves only the first path segment. The submission lock scans UUIDs in the path only, so even a frozen submission's rows are writable this way.

Exploit scenario. The attacker creates their own submission S with one document D, harvests a victim row id from the unscoped GET /api/documents/:id response, then POSTs /api/submissions/S/documents/D/fields/confirm with {"entityType":"pemegangSaham","entityId":"<victim row id>"}. The victim's shareholder row is marked notaris-confirmed with a snapshot of its current values.

Preconditions.
- Any valid session token, plus a submission and document the attacker owns.
- A victim row id, obtainable from /api/documents/:id or a review payload.
- Exploitable with SECURITY_ENFORCE=on.

Fix. Resolve each row kind back to its documentId inside resolveFieldRef or confirmWithin and require it to equal the path docId already proven to belong to the path submission; reject the write otherwise.

Verification. 3/3 lens verifiers confirmed.

F12 — GET /api/analytics/submissions lists every user's submissions with no owner or domain filter (MEDIUM, confidence high)

Impact. Any authenticated persona, including a self-service applicant, receives the 50 newest submissions of every owner and every product area — ids, statuses, document summaries and raw error strings. Those ids are the enumeration primitive the per-flow IDORs need: the same persona can feed them straight into the unscoped PP and Apostille per-id routes.

Where. backend/src/routes/analytics.ts:119 in the GET /api/analytics/submissions handler

What. The query filters on status only — no ownerId predicate and no type or domain predicate — on a mount declared AUTHED, which admits every persona. security/owner.ts listOwnerScope is the designated list-scoping helper and returns a real filter for the applicant and notaris personas, but it is applied only in the submissions and apostille lists; domains.ts typesForRole, written for exactly this purpose, has no production caller at all. The object-scope middleware does not cover this mount, and the only restriction on the page is a client-side role guard in the frontend router.

Exploit scenario. A perseroan applicant calls GET /api/analytics/submissions?filter=all and receives other applicants' and other notaries' submission ids and metadata; those ids then work against GET /api/pp/pendirian/:id, which returns another applicant's full submission with documents and identity matches, and against the PP packet download.

Preconditions.
- Any valid session token, of any persona.
- Exploitable with SECURITY_ENFORCE=on.

Fix. Apply listOwnerScope(c) and a typesForRole(role) filter to this query, and to the tasks listing, which leaks every owner's original filenames the same way. Consider restricting the analytics mount to staff roles.

Verification. 3/3 lens verifiers confirmed.

F13 — A committed Postgres password is the live credential, and the demo stack publishes the database port on all interfaces (MEDIUM, confidence high)

Impact. The password protecting the database of extracted identity data — NIK, addresses, dates of birth, passport numbers, NPWP — is in the repository. On the demo stack the port is published with no bind address on the host the ops notes describe as carrying the public IP. Direct database access bypasses the entire application security spine.

Where. docker-compose.yml:66 in services.postgres.environment (same literal at infra/compose.staging.yaml:39 and as the fallback DSN in backend/src/lib/db.ts:5)

What. The literal ahu_dev is both the password the container is created with and the password in the application's DSN, so it is not a placeholder. The postgres service has no env_file and the value is a bare literal rather than a ${VAR} reference, so no untracked env file can override it — and the deploy script ships infra/compose.staging.yaml verbatim, where the same literal is the live staging database password. The staging compose deliberately binds its port to an RFC 1918 address; the root compose, which the ops notes place on the edge host, does not.

Exploit scenario. Anyone with repository access, or anyone who reaches TCP/5433 on the demo host, connects with psql as user ahu with password ahu_dev and dumps the Document, ExtractedField and KTPExtraction tables. No application-layer control is involved.

Preconditions.
- Repository read access, or network reach to the published database port.
- The staging leg additionally requires LAN or tunnel position. The internet-facing leg could not be confirmed without probing the host firewall, which this scan did not do.

Fix. Parameterise the password as ${POSTGRES_PASSWORD:?} sourced from the already-untracked env file in both compose files, remove the fallback DSN from backend/src/lib/db.ts, rotate the value on every host that has run it, and bind the published port to loopback or drop the publish entirely.

Verification. 3/3 lens verifiers confirmed.

F14 — The Keycloak handoff audience check is skipped when KEYCLOAK_CLIENT_ID is unset, while the SSO exchange still goes live (MEDIUM, confidence medium)

Impact. In a deployment that sets the URL and realm but not the client id, any RS256 token signed by that realm — issued to any client in it — is accepted and exchanged for one of our sessions bound to that user's id. The realm is documented as shared across the AHU portal, so tokens minted for sibling applications become entry points.

Where. backend/src/security/keycloak-verifier.ts:139 in verifyKeycloakToken

What. The audience check is written if (clientId && !audienceMatches(payload, clientId)), and clientId defaults to the empty string. isSsoConfigured() requires only keycloakUrl and keycloakRealm, and the route's 503 guard consults only that, so nothing prevents the exchange running with no audience binding; no boot-time validation requires the client id, and it appears nowhere in .env.example or the ops notes. The same empty value collapses rolesClient, so resource_access[""] yields no roles and the accepted stranger receives the applicant persona rather than a staff one — which still reaches the unscoped mounts in F2 and F12.

Exploit scenario. Ops enables SSO with KEYCLOAK_URL and KEYCLOAK_REALM and omits KEYCLOAK_CLIENT_ID; nothing fails and the exchange starts working. An operator of another application in the same realm, or anyone who captures one of its access tokens, POSTs it to /api/auth/sso as handoffToken and receives a valid session for that user's identity.

Preconditions.
- KEYCLOAK_URL and KEYCLOAK_REALM set while KEYCLOAK_CLIENT_ID is left at its empty default.
- A signature-valid token issued by that realm to any client, carrying the djahu_userId claim.
- The SSO path is dormant in the shipped configuration; one panel voter judged that dormancy sufficient to refute the finding, which is why confidence is medium.

Fix. Make the audience check unconditional and treat an empty client id as a misconfiguration: include it in isSsoConfigured() so the exchange returns 503, or return null from verifyKeycloakToken when no client id is configured. Do the same for rolesClient rather than letting it degrade to an empty key.

Verification. 2/3 lens verifiers confirmed.

F15 — The dev login mints admin and both verifikator personas from one shared secret, with no production guard and no throttling (MEDIUM, confidence medium)

Impact. One shared secret gates all seven personas, and the allowlist puts admin beside the public self-service roles. Any legitimate low-privilege user who holds that secret self-promotes to admin by changing one JSON field — reaching /api/admin, every domain policy, and the staff bypass that waives per-object ownership everywhere. Guessing attempts are unlimited: the rate limiter cannot return 429 as wired, no lockout or failure counter exists, and neither success nor failure writes a security-audit event.

Where. backend/src/routes/auth.ts:76 in issueDevToken

What. POST /api/auth/login is on the PUBLIC policy and mints a session JWT for any role the caller names. The only gate is a truthiness test on SECURITY_DEV_LOGIN_SECRET — no environment or deployment check anywhere on the route, no minimum-length validation in the env schema (a one-character or whitespace secret enables it), and the comparison is a plain non-constant-time !==. installSecurity types the limiter mode so it can never hold "on", so RATE_LIMIT_MODE=on is downgraded with a warning and the 429 branch is unreachable; its key is a client-supplied X-Forwarded-For header in any case. The ops notes record the secret as set on the live host, and with the Keycloak path dormant this is the only way to obtain a token at all once enforcement is flipped on.

Exploit scenario. Any user who is given the secret to log in as an applicant POSTs {"role":"admin","secret":"<the shared secret>"} instead and receives an 8-hour admin JWT, which authenticate accepts with no issuer or subject-shape check. Separately, an attacker scripts guesses against the endpoint with nothing to rate-limit, lock out, or record them.

Preconditions.
- SECURITY_DEV_LOGIN_SECRET is set — required today for anyone to log in once SECURITY_ENFORCE=on, since the SSO path is dormant.
- For the self-promotion path: possession of the shared secret, which every legitimate user needs.
- For the unauthenticated path: a weak or leaked secret. One panel voter refused the finding on the grounds that guessing a strong secret is not a demonstrated path, which is why confidence is medium.

Fix. Refuse to enable the route outside development via an explicit environment allowlist rather than an unset variable; drop admin and the verifikator personas from the mintable roles so the bootstrap path cannot mint the highest privilege; require a minimum secret length at boot; compare with crypto.timingSafeEqual over fixed-length digests; let the rate limiter actually enforce, with a real per-IP failure counter on this route; and emit a security-audit event on every issuance.

Verification. 2/3 lens verifiers confirmed.

F16 — The thumbnail route serves any document id, inside the mount the per-object guard is meant to protect (MEDIUM, confidence medium)

Impact. Image-typed documents — the common format for KTP photographs — are streamed in full to a caller who supplies any document id alongside a submission id. Because the object-scope middleware passes through when the path submission does not exist, even a bogus submission id works, so the domain and ownership checks are skipped entirely.

Where. backend/src/routes/submissions.ts:2147 in the GET /:id/documents/:docId/thumbnail handler

What. scopeSubmissionObjects validates only the first path segment after the mount, and this handler then resolves the file by the second id — db.document.findUnique on docId — without the SubmissionDocument membership check its sibling routes perform (the field patch, the delete and the retry handlers all do it). GET requests are not touched by the submission lock. PDFs return a 1x1 placeholder, which bounds the exposure to non-PDF uploads.

Exploit scenario. The attacker requests GET /api/submissions/<any-id>/documents/<victim-doc-id>/thumbnail. The middleware finds no submission for the first id and calls next(), the handler looks the document up by the second id alone, and the victim's KTP image bytes are returned.

Preconditions.
- Any valid session token.
- A victim document id, enumerable via the unscoped /api/documents listing.
- The victim document is stored as an image rather than a PDF.
- One panel voter judged this redundant, since /api/documents/:id/pdf already exposes the same bytes more directly (F2), which is why confidence is medium.

Fix. Look the document up through SubmissionDocument scoped to the path submission id, as the sibling routes already do, and make scopeSubmissionObjects deny rather than pass through when the path submission does not exist.

Verification. 2/3 lens verifiers confirmed.

F17 — An applicant-set bypassValidations flag waives the expedited-lane payment check and the submit gates (MEDIUM, confidence medium)

Impact. An Apostille applicant can promote their own request into the verifikator queue on the paid expedited lane without a verified billing voucher, and with failing cross-validations, unconfirmed fields and unresolved signers. The payment check waived here is the only enforced billing control on that lane, and the request still receives the priority SLA position in the inbox.

Where. backend/src/routes/apostille.ts:731 in the POST /:id/submit handler

What. The submit route has no role guard — unlike its sibling /:id/decide, which calls requireVerifikatorRole — and the APOSTILLE_FLOW policy admits the applicant persona. A boolean read straight out of the JSON body then disables four gates: the PERCEPATAN billing re-verification, the un-overridden FAIL gate, the all-fields-confirmed gate and the signer-resolution gate. A backend-wide search finds no role, environment or app-setting condition on the flag anywhere; the frontend even ships the toggle to the applicant's page ungated.

Exploit scenario. An umum applicant creates a PERCEPATAN request, never pays, waits for it to be ready, then POSTs {"bypassValidations":true} to /api/apostille/<id>/submit. The payment, validation, confirmation and signer checks are all skipped and the submission enters IN_VERIFICATION in the priority lane.

Preconditions.
- An APOSTILLE_FLOW session and a submission in a submittable status.
- The billing consequence bites only when FASTTRACK_PAYMENT_BYPASS is turned off, since it currently defaults on.
- The FAIL and signer gates recur at the verifikator's approve step, so this is process integrity and queue pollution rather than a directly issued apostille. One panel voter refused the finding on that basis, which is why confidence is medium.

Fix. Gate the flag behind both a staff role check and an explicit development-mode configuration value, and never let it waive the expedited-lane payment verification.

Verification. 2/3 lens verifiers confirmed.

What was verified

Fifteen researchers mapped and hunted the scope across thirteen components and three category lenses, plus a cross-component sweep and a secrets pass; 46 raw candidates deduplicated to 31. Eighteen were put to a fixed three-voter adversarial panel — one voter per lens (reachability, impact, defenses), each instructed to default to false-positive and to refute only with a mitigation it had located and read — for 54 independent votes. Seventeen findings reached the two-of-three keep quorum and are reported above; thirteen of them unanimously. One candidate was rejected and is described in the Coverage section. Thirteen lower-ranked candidates fell below the verification cap and were neither verified nor reported. The renderer stamped this report's verification.status from that vote record.