API Gateway security#
The API Gateway (apps/gateway, port 3001) stores per-endpoint credentials for third-party APIs — API keys, bearer tokens, basic-auth username/password — and injects them server-side on every proxied call. Those credentials are the gateway's core sensitive asset: anyone who obtains one gets whatever access the configured endpoint grants, at the third party, outside this system entirely. This doc covers how that asset is protected today, and what's intentionally deferred.
Threat model#
- A DB read (a backup, a read replica, a compromised service sharing the database, SQL injection elsewhere) should not hand over usable third-party credentials.
- A client with
GATEWAY_MANAGE(the admin-dashboard's Gateway Endpoints page) should be able to configure credentials without the API ever having to return them — nothing legitimately needs to read a stored secret back once it's set. - An error response should never be a side channel for internal detail — an upstream URL (which can embed a secret if someone pastes one into a query string instead of
authConfig), an internal hostname, or a stack trace. - An upstream response relayed back to a browser (the "Try it"/execute tool, a captured schema sample) should not carry the upstream's own secrets — a
Set-Cookie, a session token, a field that happens to be namedpassword— along for the ride.
Credentials at rest: encryption#
gateway_endpoints.auth_config used to be a plain jsonb column. It's now encrypted:
packages/core/src/db/gateway-auth-crypto.ts— AES-256-GCM, authenticated (a tampered/corrupted ciphertext fails to decrypt rather than silently returning garbage credentials). The key is derived viascryptSyncfrom theGATEWAY_AUTH_ENCRYPTION_KEYenv var, so an operator doesn't have to hand-generate exactly-32-byte key material. Ciphertext is stored asiv (12 bytes) + authTag (16 bytes) + ciphertext, base64-encoded, ingateway_endpoints.auth_config_encrypted.- This mirrors an existing pattern in this codebase —
packages/core/src/db/migration-sources-crypto.tsencrypts a stored Pimcore DB password the same way — but uses its own env var rather than reusingMIGRATION_SOURCE_ENCRYPTION_KEY, so a leak of one secret class doesn't compromise the other. - Encryption/decryption happens at the records layer (
packages/core/src/db/gateway-records.ts'stoRow/toRecord), not at each call site. Every function outside that file (admin-routes.ts,resolve-handler.ts,remote-router.ts) still just readsendpoint.authConfigas a plain object — they don't know or care that it's encrypted on disk.
Migration state: expand → backfill → contract#
Changing how a live secret column is stored is not a one-shot edit. This shipped as:
- Expand (
packages/core/drizzle/0039_gateway_auth_encrypted.sql) — addauth_config_encrypted, additive only. The old plaintextauth_configcolumn stays in place. - Backfill (
packages/core/src/db/scripts/backfill-gateway-auth-encryption.ts, run viapnpm --filter @whoobe-cms/core run db:backfill-gateway-auth) — encrypts any row still holding a plaintextauth_configintoauth_config_encrypted, then clears the plaintext column. Idempotent; safe to re-run. - App-layer write-through — as of this change, every
createGatewayEndpoint/updateGatewayEndpointcall encrypts and clears the legacy column immediately, so a row touched even once is no longer exposed there. - Contract (not yet done) — a future migration drops
gateway_endpoints.auth_configentirely, once every environment has run the backfill script. It's deliberately not bundled with step 1: applying migrations on a checkout with existing plaintext data, without running the backfill first, would otherwise delete credentials with no encrypted copy to fall back to.
Operational note: any environment that already had gateway endpoints configured before this change (including this repo's own local dev DB, which it was run against) must run the backfill script once, after setting GATEWAY_AUTH_ENCRYPTION_KEY, before the eventual contract migration ships.
Credentials never round-trip to a client#
GET /api/v1/gateway/endpoints, GET /api/v1/gateway/endpoints/:id, POST /api/v1/gateway/endpoints, and PATCH /api/v1/gateway/endpoints/:id all strip authConfig before responding (admin-routes.ts's redactEndpoint()). This matters even for an authorized GATEWAY_MANAGE caller: previously, just opening the admin-dashboard's Gateway Endpoints list fetched every endpoint's raw credentials to the browser, whether or not anyone intended to view or edit them — visible in DevTools' network tab, to any browser extension with page access, to error-tracking tools that capture responses, regardless of the edit form's type="password" masking (which only affects display, not what already crossed the wire).
Because credentials no longer come back from the API, editing is write-only:
- The admin-dashboard's edit form (
GatewayEndpointsView.vue) starts credential fields blank on Edit, with a note that a blank field keeps the current stored value. PATCH /api/v1/gateway/endpoints/:idmerges the incomingauthConfigonto the existing stored one, ignoring blank/omitted keys, rather than overwriting wholesale — so renaming an endpoint, or changing just one credential field, can't accidentally wipe the rest.
Response sanitization#
apps/gateway/src/redact.ts:pickSafeResponseHeaders()— the admin "Try it" tool (POST /routes/:id/execute) only relays an allow-list of upstream response headers (content-type,content-length,cache-control,etag,last-modified,date,x-request-id) to the browser, instead of every header the upstream sent. A raw pass-through would hand aSet-Cookie, a trace/rate-limit token, or — on a misconfigured upstream — an echoedAuthorizationheader straight to the client.redactSensitiveValues()— recursively scrubs any JSON key matching a credential-shaped pattern (password,secret,token,api[_-]?key,authorization,access[_-]?key,client[_-]?secret,session[_-]?id,cookie, etc.) to[REDACTED]. Applied to the sample response captured by "Test route" and "Refresh schema" before it's persisted asgateway_schemas.sample_payloadand before it's returned in the response — this is a heuristic (key-name matching), not a guarantee that every sensitive value in every possible upstream shape is caught, so avoid testing routes against endpoints whose responses carry sensitive data you don't want retained at all.
Error handling#
Every gateway route that proxies an upstream call, or performs a DB operation, logs the real error server-side (apps/gateway/src/log-error.ts's logGatewayError()) and returns a fixed, generic message to the caller. Previously, catch blocks returned details: String(err) directly — a failed fetch's error message typically includes the full request URL, which (for an endpoint whose credentials were pasted into baseUrl/a route path instead of authConfig) could include that secret, plus internal hostnames and stack detail with no benefit to the caller.
Access control (unchanged, noted for completeness)#
- All admin CRUD/test/execute routes require the
GATEWAY_MANAGEpermission (apps/gateway/src/auth.ts). /api/internal/*and/dam/*(internal service proxies) require a valid session (requireAuth)./public/*requires no auth by design — it proxies tocore-api's own already-public routes, injecting no gateway-managed credentials.- The resolve endpoint used by page rendering (
/api/v1/gateway/resolve) only ever returns{ endpointName, fieldPath, value, found }— never headers, neverauthConfig— regardless of the caller.
Known gaps / deliberate follow-ups#
- Key custody:
GATEWAY_AUTH_ENCRYPTION_KEYlives in process env (.env/ the container's environment). Anyone who can read the gateway process's runtime config can decrypt every stored credential. A managed KMS (envelope encryption, per-secret data keys, centralized rotation/audit) would remove that single point of compromise, at the cost of a new external dependency and a network call per decrypt — not something this repo has any existing integration for, so it's a larger, separate piece of work. - No key rotation: rotating
GATEWAY_AUTH_ENCRYPTION_KEYtoday means re-encrypting every row by hand (decrypt with the old key, re-encrypt with the new one) — there's no tooling for this yet. - The legacy
auth_configcolumn still exists pending the contract migration described above. - Sample-payload redaction is heuristic, not exhaustive (see above).
Last updated