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
Scope & how to read this
Section titled “Scope & how to read this”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).
A01–A10 status
Section titled “A01–A10 status”| # | 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.
Accepted-risk register
Section titled “Accepted-risk register”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).
Pre-prod security gates
Section titled “Pre-prod security gates”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, mobilesentry-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 13TODO(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 pushgated 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+ theverifyVendoredBinariesGradle task, CI-enforced (#154/#147). - Web
appwrite.jsonclient-create regression closed — thecreate("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
developmentfromAUTO_DDL_ENVSbefore 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).
Maintenance contract
Section titled “Maintenance contract”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/missingcell 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.
Sources
Section titled “Sources”- Posture scans of origin/main (2026-09-08): terminales-api
e5aa9ef, terminales-webac048de4, terminales09160e07. 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).