Skip to content

Security baseline — OWASP Top 10

Purpose: the durable, always-current security baseline for the solution: OWASP Top-10 (2021) A01–A10 status per repo (terminales-api / terminales-web / terminales[mobile]), the accepted-risk register, and the pre-prod security gates. Every audit UPDATES this file — it is a living register, not a one-off report. It records status + owner + expiry; it does not re-argue decisions. Status: as of 2026-09-08. First living cut, produced by a fresh origin/main posture scan (below). It supersedes the point-in-time Wave-2 audit snapshot in wave2-mvp-analysis §3.1 for A01–A10 status — that snapshot (2026-07-06) predates the SecurityBaseline hardening wave and now reads as history. Related: wave2-mvp-analysis (§3.1 seed + §4.1 row 20 rationale + §3.4 M6 gate list) · production-cutover (the cutover-day gate table this register feeds) · ../progress/milestones.md (M6 closed 2026-07-24, M11 go-live blockers) · ./auth-and-multitenancy.md · ./roles-permissions-and-labels.md · AI_CONTEXT

This baseline covers the three code repos and the Appwrite backend. Fulfils terminales-api issue #266 (“Multi DOCS SecurityBaseline OWASP Baseline Document”), whose deliverable belongs here in terminales-solution/docs/ rather than the api repo. The nine sibling SecurityBaseline tickets own the actual remediation code; this file is the registry they hang their status, residuals, and gates on.

Statuses are covered / partial / missing / n/a, judged against the current merged code, not intended fixes. A partial/missing cell names the ticket that owns closing it. The accepted-risk register below is the part the Product Owner (Alfredo) triages — every row is a candidate with Owner: TODO(human) and a suggested expiry, waiting for him to assign an owner, set an expiry, and confirm the accept-vs-fix recommendation. This is a security document: no control is claimed present unless it was seen in the code, and anything that a scan cannot fully verify is marked “unverified — needs review” rather than asserted.

Scan basis (origin/main, 2026-09-08): terminales-api @ e5aa9ef · terminales-web @ ac048de4 · terminales (mobile) @ 09160e07. Working trees were on feature branches and were deliberately NOT used. pnpm audit figures ran against the working-tree dependency sets and are approximate (transitive advisories move daily).

# terminales-api terminales-web terminales (mobile) Fix owner (ticket)
A01 Broken Access Control covered — AppwriteAuthGuard re-validates identity server-side every request (src/auth/appwrite-auth.guard.ts), AppwriteScopeGuard + @RequireScopes enforce store-team role scopes with 403 (src/auth/appwrite-scope.guard.ts, #157); PlatformAdminGuard/assertPlatformAdmin gate the admin-override endpoint (src/verification/http/admin/…-admin.controller.ts, #674); default-deny capability gate (merchant-capabilities.service.ts, #630); cache.controller.ts now @UseGuards(AppwriteScopeGuard). Caveat: authn is opt-in per controller (no global APP_GUARD — the throttler is the only APP_GUARD), mitigated by CI guard/scope-coverage sweeps that fail the build on an unguarded route (src/auth/guard-coverage.spec.ts, #443). partial — edge gate src/proxy.ts (Next 16 renamed middleware.ts→proxy.ts) is a cookie-presence check by design; real validation is server-side getServerSession() (src/core/auth/serverSession.ts, account.get()) with real consumers requireServerSession/requireAdminServerSession/requirePlatformAdminServerSession/requireWalletOwnerSession (#175). The old unscoped Voids limit:1000 cross-store leak is fixed — server-scoped by storeId+open-shift since, query disabled until storeId known (terminal/voids/VoidsView.tsx, #247). Residual: appwrite.json client-side create("users") perms (see A08). partial (by design) — client-side defence-in-depth only; server RLS is the real boundary. Tenant scoping CatalogDao WHERE tenantId=:tenantId + SessionTenantProvider (returns the real persisted tenant in prod, degrades to empty cache — never another tenant’s rows); DefaultUploadAuthorizer.canUpload() fails closed (manager-role only), documented as UX gate (#100). web #608 (scoping-contract gaps), api #428 (cross-store authz sweep) — residual polish only
A02 Cryptographic Failures covered — AES-256-GCM for stored PSP/connection secrets (psp-credentials/psp-credentials.crypto.ts, platform-connections/…crypto.ts; random IV + auth tag; keys PSP_CREDENTIALS_ENCRYPTION_KEY/PLATFORM_CONNECTIONS_ENCRYPTION_KEY from env, presence-checked at boot, never logged); PIN hashing scrypt + timingSafeEqual with a decoy-hash timing equalizer for the empty-find path (auth/pin-login/pin-login.service.ts). covered — session cookie httpOnly+sameSite=lax+ transport-aware Secure (core/auth/sessionCookie.ts buildSessionCookieOptions/isSecureRequest, honors x-forwarded-proto, PR #804); APPWRITE_API_KEY behind import 'server-only' (core/appwrite/appwriteServer.ts); JWT/session secret only in the httpOnly cookie, never in the client bundle. covered (with one accepted gap) — EncryptedSharedPreferences AES256-GCM master key (auth/EncryptedSessionStorage.kt), JWT @Volatile memory-only (auth/SessionManager.kt), PIN never stored; allowBackup="false" + backup/data-extraction rules exclude the DB, session prefs, datastore. Room DB is unencrypted (no SQLCipher) and there is no TLS cert pinning — see register. mobile #198 (Room at-rest encryption evaluation) for the residual
A03 Injection covered — global ValidationPipe {whitelist, forbidNonWhitelisted, transform} (src/main.ts); Appwrite Query.* builders throughout; Postgres remainder uses $1/$2 bind params (reports/services/report.service.ts); raw SQL only in migration DDL. covered — zero dangerouslySetInnerHTML/eval/new Function in src; React escaping intact; typed SDK/OpenAPI queries. covered — all Room @Query parameterized (:tenantId/:id/:clientId), typed SDK ops, kotlinx.serialization JSON. — (zero unmitigated gaps; keep the pattern — enforced by this baseline)
A04 Insecure Design covered — money-path races handled by DBOS durable workflows keyed on clientId as the dedupe id: pos/sales/finalize-workflow.ts, settlement-workflow.service.ts, card-charge-workflow.ts, vas-fulfillment-workflow.service.ts; assertClientId + (storeTeamId, clientId) unique index (payment-intents/angelpay-card.adapter.ts). The July TOCTOU/stock-decrement races are the ones these workflows close. covered — POS finalizeOrder carries a clientId UUID idempotency key (terminal/_cart/finalizeOrder.ts); coarse errors, PIN never logged. partial — idempotent write path (clientId = documentId, 409 swallowed as no-op; RealtimePlusPoll de-dups). Gap: PIN lockout is server-side only (client renders the server’s 429/423 blocked state; no local attempt counter/backstop). register (mobile client-side PIN backstop — low)
A05 Security Misconfiguration covered (one pre-go-live flag to close) — helmet + fail-closed allowlist CORS + SWAGGER_ENABLED-gated docs (bootstrap/http-hardening.ts, #183); TypeORM synchronize OFF in prod/stage + migrationsRun:false (database/database.options.ts). Residual: AUTO_DDL_ENVS={'development'} leaves auto-DDL ON in dev with an explicit “REMOVE before go-live” code comment. FORCE_HTTPS — unverified in-app (TLS terminates at the shared traefik per infra). covered — central core/security/securityHeaders.ts wired at next.config.ts for every route: enforcing CSP (default-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests), HSTS max-age=63072000; includeSubDomains, X-Frame-Options: DENY, X-Content-Type-Options, Referrer-Policy, Permissions-Policy (#176). Safari-compat PR #804 omits upgrade-insecure-requests+HSTS in dev only. CSP uses 'unsafe-inline' (MUI/Emotion) — accepted-risk, see register; not nonce-based, not report-only. covered — the July “HttpLoggingInterceptor at Level.BODY unconditionally” finding is fixed: Level.BODY only when BuildConfig.DEBUG, else NONE, test-guarded (core/graphql/ApolloGraphQLClient.kt, #195); release isMinifyEnabled=true+isShrinkResources=true; targetSdk=36. No network_security_config.xml — unverified, relies on API-28+ cleartext-off default (register). web #176 (nonce-CSP follow-up), api #183 (dev auto-DDL removal) — residuals in register
A06 Vulnerable & Outdated Components partial — pnpm audit --prod (approx): 7 transitive vulns (4 high brace-expansion DoS, moderate qs, uuid) — no direct-dep criticals; better-auth confirmed removed. Dependabot on (.github/dependabot.yml); no CodeQL; gitleaks on PR+push. partial — pnpm audit --prod (approx): 4 vulns (2 moderate/2 low) all dompurify via @monaco-editor/react (admin editor); no direct-dep vulns. Dependabot on; no renovate, no CodeQL; gitleaks present but non-blocking (continue-on-error, main-only). covered — renovate.json (weekly, security-labeled, patch/digest automerge) + gitleaks CLI; vendored blobs (angelpaySDK .aar, Vanstone JARs) checksummed by config/vendored-binaries.sha256 + verifyVendoredBinaries Gradle task, CI-enforced (#154, #147). web #174 (dep-scanning rollout), api #249 (dependabot rollout — landed), mobile #154 (done)
A07 Identification & Authentication covered — identity re-validated server-side every request; global per-IP throttler Redis-backed (rate-limit/rate-limit.module.ts, RedisThrottlerStorage, #182); PIN-login failure limiter + lockout Redis-backed in prod (auth/pin-login/pin-attempt-store.ts: 5 fails/60s window, lockout after 3 windows for 15 min). covered — POST /api/auth/session calls checkSessionRateLimit before any Appwrite call (10/15min, IP+sha256(email) keyed, oracle-free, 429+Retry-After; #177). Caveat: default store is an in-memory Map (per-serverless-instance) — Redis store is a follow-up behind the same interface (register). covered — 15-min JWT TTL + 60s early-refresh, mint serialized under a mutex, retry-once-on-401 (auth/SessionManager.kt, JwtAuthInterceptor.kt); magic-link/recovery now on https App Links (autoVerify=true, assetlinks.json committed) with terminales:// kept only as fallback (#263/#187). web #177 (Redis-backed limiter); register (assetlinks must be hosted at prod domain)
A08 Software & Data Integrity covered (typecheck gap) — test-gate.yml runs pnpm test+test:e2e on every PR+push (frozen lockfile, #175); schema-as-code validated (config-validate.yml, #539) + migrations-only DDL in prod; clientId idempotency required. Minor: no standalone typecheck step (nest build not run in the gate). partial — finalizeOrder clientId idempotency + CI quality/type/e2e gates. Regression: appwrite.json declares client-side create("users") perms on some tables (the known terminals/customers client-create issue) — verify per-table intent, treat as a prod blocker. covered — verifyAppwriteModels drift gate is a required CI check; release signing built only from CI secrets (signingConfig=null otherwise, no committed keystore); vendored-binary checksum gate (A06). web #608 + terminals/customers client-create regression (prod blocker); api typecheck step (register — low)
A09 Security Logging & Monitoring covered — pino + OTel (instrumentation.ts, observability/otel/*) + Prometheus/Grafana; append-only audit_log with exhaustiveness-tested sensitive-op coverage (pos/audit/*, SENSITIVE_OPS_INVENTORY.md, #184/#103); impersonation audited; secret redaction test-proven. covered (runtime-gated) — @sentry/nextjs genuinely installed + configured (client/server/edge configs, withSentryConfig); DSN-gated + flag observability_sentry_web; structured web_session_* logs. The transport sink is a pending PO decision (§4.6) — Sentry is wired, the org-wide transport is not chosen. covered (runtime-gated) — Sentry a real dep, gated by a non-blank SENTRY_DSN (blank on CI/fresh checkout, #195); over-log risk closed by the A05 DEBUG gate; upload authz logs roles only, never userId/tenantId. Telemetry-transport gate (§4.6 / ADR-0003) — see gates + register
A10 SSRF covered — all outbound URLs are fixed-config (env/secret-file), not request-supplied: Mantarys MANTARYS_WS_URL, SMS-OTP endpoint, Telegram, Loki. Minor: Mantarys endpointOverride (#484) can come from a platform-connection row set by a platform admin (audited, not user-supplied) — a config-trust boundary, in register. n/a — no server route does user-URL-driven fetch; server fetches hit fixed Appwrite/API origins. n/a — endpoints are build-time BuildConfig constants, never user-controlled. register (api Mantarys config trust boundary — low)

Categories with zero unmitigated gaps across all three repos: A03 (Injection). All others carry at least one residual tracked in the register or gates below.

Every row is a CANDIDATE for the PO (Alfredo) to triage: assign a real Owner, set/confirm the Expiry, and confirm the accept-vs-fix call. Owner: TODO(human) and expiries are suggestions, not decisions. Ordered by severity.

Risk Repo Severity Owner Expiry / re-evaluate Recommendation Removal path / notes
appwrite.json grants client-side create("users")/read("users") on some tables (terminals/customers client-create regression) web High TODO(human) before first prod deploy fix-before-prod Re-scope to server-authored writes; verify each table’s $permissions + rowSecurity against the canonical api schema. Owner ticket: web #608 (scoping contracts) + the standing regression. Confirm intent per table before go-live.
Web /api/auth/session rate-limiter is an in-memory Map (per-serverless-instance on Vercel) — no shared store web Medium TODO(human) before real traffic / ≥2 instances fix-before-prod Swap in the Redis-backed store behind the existing sessionRateLimit interface (web #177 follow-up). Same class of gap as the api limiter, which is already Redis-backed (#182).
Web CSP uses 'unsafe-inline' for script-src and style-src (MUI/Emotion runtime styles) web Medium TODO(human) before public launch (nonce follow-up) accept (MVP) Removal = nonce-based CSP + CSP reporting; documented in securityHeaders.ts. Enforcing CSP with frame-ancestors 'none' already ships (#176).
Mobile: no TLS certificate pinning (CertificatePinner) for a payments client mobile Medium TODO(human) before prod (evaluate) fix-before-prod (evaluate) Unverified whether intentional — needs review. Evaluate pinning the Appwrite + Nest origins; weigh against cert-rotation ops. New finding (not in the July audit).
No CodeQL / SAST workflow in any of the three repos (dependency scanning exists, static analysis does not) multi Medium TODO(human) before public launch accept (MVP) → fix post-launch Add CodeQL (or equivalent) to CI. Dependency advisories are covered (dependabot/renovate); source SAST is the gap.
API auth is opt-in per controller (no global APP_GUARD); a guard-less controller is a build failure, not a runtime deny api Medium TODO(human) ongoing (keep CI sweep green) accept Mitigated by reflection-based guard-coverage.spec.ts/scope-coverage.spec.ts (#443) that fail CI on any unguarded/unscoped route. Re-evaluate if a global guard becomes cheap.
Room DB unencrypted (no SQLCipher) on the mobile device mobile Low–Medium TODO(human) revisit before any PII/PAN lands in cache accept Mitigated: allowBackup=false + backup/data-extraction excludes; cache holds catalog + sync_queue only. Owner: mobile #198. Flip to fix the moment sensitive data is cached.
API AUTO_DDL_ENVS includes development — TypeORM auto-DDL ON in dev api Low TODO(human) before go-live fix-before-prod Code comment already says “REMOVE before go-live.” Prod/stage are OFF; this is a dev-only cleanup so prod cannot accidentally inherit it. Owner: api #183 residual.
API pnpm audit: 7 transitive advisories (4 high brace-expansion DoS, qs, uuid) api Low–Medium TODO(human) next dependabot cycle accept All transitive, no direct-dep criticals; dependabot will bump. Re-check at each audit. Owner: api #249/dependabot.
Web dompurify transitive vulns (2 moderate/2 low) via @monaco-editor/react web Low TODO(human) next dep-bump cycle accept Admin-only Monaco editor; patched in dompurify ≥3.4.12, needs a monaco bump. Owner: web #174/dependabot.
Web gitleaks is non-blocking (continue-on-error, push-to-main only, no PR trigger) web Low TODO(human) before prod fix-before-prod (cheap) Make the scan blocking and add the PR trigger (api runs it on PR+push). Owner: web #174.
API test-gate has no explicit typecheck step (nest build not run in the gate) api Low TODO(human) before go-live fix-before-prod (cheap) Add a tsc --noEmit/nest build step so a type error fails CI, not just tests.
Mobile PIN throttle is server-only (no client-side attempt backstop) mobile Low TODO(human) post-MVP accept Client renders the server’s 429/423 lockout; server is the authority. A local counter is defence-in-depth only.
Mobile: no explicit network_security_config.xml mobile Low TODO(human) before prod (add explicit config) accept targetSdk=36 disables cleartext by default; an explicit config is hardening, not a fix.
Mobile App Links assetlinks.json must be hosted at the production domain (custom-scheme terminales:// remains as fallback) mobile Low TODO(human) before go-live accept App Links landed (#263/#187) with assetlinks.json committed; the go-live task is hosting it at the prod domain and confirming autoVerify.
API Mantarys endpointOverride (#484) — outbound URL can come from a platform-connection row set by a platform admin api Low TODO(human) document + restrict to platform_admin accept Admin-configured + audited, not request-supplied → not classic user-SSRF, but a config-trust boundary worth stating.
Web session cookie 30-day maxAge fallback (carried from the Wave-2 A02 caveat) web Low TODO(human) verify current value accept Unverified against current code — needs review. Cookie is now transport-aware Secure+httpOnly+lax (#804); confirm the effective maxAge matches policy.

Recently mitigated — dropped off the register since the Wave-2 audit (2026-07-06)

Section titled “Recently mitigated — dropped off the register since the Wave-2 audit (2026-07-06)”

Recorded so the PO can see what closed (each was a July partial/missing/accepted-risk, now covered on origin/main): API verifyPin timing oracle → decoy-hash equalizer (was api todo #90); API helmet/CORS-allowlist/Swagger-gate/synchronize:false (#183); API Redis rate-limit + PIN lockout (#182); mobile BODY-level HTTP logging → DEBUG-gated (#195); mobile vendored-AAR checksums → verifyVendoredBinaries + vendored-binaries.sha256 (#154/#147); mobile magic-link deep links → https App Links + committed assetlinks.json (#263/#187); web security headers/CSP/HSTS shipped (#176, Safari-compat #804); web Voids cross-store limit:1000 leak → server-scoped (#247); web SSR session-guard consumers wired (requireServerSession et al., #175).

The checks that must pass before go-live. These feed the cutover-day gate table in production-cutover — that runbook is the execution sequencer; this list is the security subset with current state. [ ] = open, [~] = substantially met with a residual, [x] = met.

  • Telemetry transport chosen and wired (web + mobile) — pending PO decision (§4.6). Sentry SDK is genuinely installed and runtime-gated on both clients (web @sentry/nextjs, mobile sentry-android), but the org-wide transport/sink is not chosen — the structured logs land in no-op sinks today. ADR-0003 (Sentry frontend + OTel/LGTM backend) may already close this; the PO must confirm it formally closes the §4.6 row (cutover runbook gate 13 TODO(human)). Recorded as an open gate, never as decided.
  • [~] Dependency scanning live in all three repos — api: dependabot ✅ (#249); mobile: renovate + gitleaks + vendored-AAR checksums ✅ (#154/#147); web: dependabot ✅ but no renovate, no CodeQL, gitleaks non-blocking (web #174). Residual: no CodeQL/SAST in any repo (register).
  • [~] GitHub environment protection provisioned — prod appwrite push gated by a required (human) reviewer. Cutover-runbook gate 1 records this as CLOSED via #163 (verify: gh api repos/inspiraCode/terminales-api/environments/production --jq '.protection_rules' → non-empty). This supersedes the Wave-2 §3.1 reference to api #97; verify, don’t redo.
  • Vendored-binary checksum policy (mobile AARs) — met: config/vendored-binaries.sha256 + the verifyVendoredBinaries Gradle task, CI-enforced (#154/#147).
  • Web appwrite.json client-create regression closed — the create("users") client-side permissions (A08) must be re-scoped and verified per-table before first prod deploy (web #608). Prod blocker.
  • Web session rate-limiter moved to a shared store — before real traffic / ≥2 serverless instances (web #177).
  • API dev auto-DDL flag removed — drop development from AUTO_DDL_ENVS before go-live (api #183 residual).
  • Feature-flag production ramp + kill-switch proven — Multi #264 (open, human-reserved); cutover-runbook gate 5. Not strictly a code control, but the go-live security-relevant switch.
  • Cutover runbook executed — the full ordered sequence in production-cutover (final backup, schema reconcile, decommission verify, deploy, flag ramp, post-cutover smoke) plus its two pending §4.6 PO decisions (telemetry transport, deploy-pipeline shape).

This file is living. Update it — and refresh the Status: date in the header — on any of:

  • A new security audit / posture scan (per wave, or ad-hoc): re-run the per-repo scan against origin/main, update the A01–A10 cells and the scan-basis SHAs, and move anything newly fixed into “Recently mitigated.” Do not create a separate one-off report — this file is the report.
  • A wave closes: reconcile every partial/missing cell against what merged; flip cells and retire register rows whose fix landed.
  • An accepted-risk expiry passes: the row re-opens as a decision — the PO either extends the expiry or the risk becomes fix-before-prod.
  • A gate changes state: update the pre-prod gates checklist and cross-check the cutover runbook’s gate table.

Keep the house style: long-line prose, no markdownlint reflow (per the repo docs convention). Cite real file paths only; mark anything a scan cannot confirm as “unverified — needs review” rather than asserting a control exists.

  • Posture scans of origin/main (2026-09-08): terminales-api e5aa9ef, terminales-web ac048de4, terminales 09160e07.
  • wave2-mvp-analysis §3.1 (the seed A01–A10 table + living-baseline proposal), §4.1 row 20 (this doc’s rationale), §3.4 (M6 gate list).
  • production-cutover — the cutover-day preconditions gate table (gates 1, 5, 13, 14 map to the gates above).
  • ../progress/milestones.md — M6 closed 2026-07-24; M11 go-live blockers include this doc (#266).
  • SecurityBaseline sibling tickets: api #182 (Redis rate-limit + lockout), #183 (runtime misconfig hardening), #184 (sensitive-ops membership validation), #157 (store role-scope authz), #428 (cross-store authz sweep), #249 (dependency scanning), #181 (critical dependency bumps), #443 (guard-coverage sweep), #630 (default-deny), #674 (admin-override); web #176 (security headers/CSP), #177 (session-route rate limit), #174 (dependency scanning), #175 (enforce server-session validation), #247 (admin data scoping), #608 (scoping-contract gaps); mobile #155 (release logging/build hardening), #154 (dependency scanning + AAR checksums), #198 (Room at-rest encryption evaluation), #195 (Sentry frontend wiring / logging level).
  • ../decisions/README.md → ../adr/0003-observability-telemetry-transport.md (telemetry transport — the §4.6 open gate).