Siirry sisältöön
ErmisAI

Security controls

Auth gates, the SSRF network guard, rate limiting, CSP, secret encryption, and bot protection.

This page inventories the security controls that exist in the ErmisAI codebase, where each one is enforced, and what it does not cover. Everything here is checkable against a file and a line. Where a control is partial, optional, or fails open, that is stated rather than glossed.

The public counterpart is https://ermisai.com/security, which carries the same posture in customer-facing language.

Request authentication

Every API route carries its own gate

There is no global middleware auth gate for API routes. src/proxy.ts composes Clerk and next-intl and injects headers; its route matchers are documented in-code as defence-in-depth only (src/proxy.ts:3-6). Each of the 88 route.ts files under src/app/api authenticates itself.

The gate mechanisms in use:

GateUsed by
Clerk session (auth(), getResolvedClerkSessionMetadata)The large majority — stories, editorial, team, billing, usage, monitoring, newsroom, notifications, alerts, admin, files, account, sources, AI
Cron bearer (isAuthorizedCronRequest) on GET, admin session on POSTThe five cron routes
Tenant API key (x-ermis-api-key)/api/integrations/cms/stories, /api/integrations/wordpress/stories, /api/integrations/wordpress/disconnect
Plan + role helpers (authorize*Access)/api/integrations/api-keys, /api/integrations/webhook, /api/integrations/webhook-logs, /api/integrations/wordpress/connections
One-time connect session (consumeWordPressConnectSession)/api/integrations/wordpress/connect/exchange
Provider webhook signature/api/webhooks/clerk, /api/webhooks/resend, /api/billing/webhooks/polar
Queue message HMACThe four /api/queues/rss/* consumers
BotID (verifyHumanRequest)/api/integrations/webhook/test, /api/newsroom/profile/custom-sources, /api/newsroom/profile/custom-sources/validate

The CI backstop

tests/api-route-auth-guard.vitest.ts walks src/app/api recursively, asserts there are more than 50 route.ts files so a glob regression cannot make the check vacuous, and requires every file to either match one of the recognised gate patterns (api-route-auth-guard.vitest.ts:23-43) or appear in the allowlist.

The allowlist has exactly two entries (api-route-auth-guard.vitest.ts:14-20):

  • src/app/api/health/route.ts — liveness and readiness probe.
  • src/app/api/openapi/public-integrations/route.ts — static OpenAPI document.

The test is textual. It proves an auth token appears in the file, not that the gate is correctly applied to every verb and branch. Behavioural role enforcement is covered separately by tests/route-audit-regressions.vitest.ts, which asserts, for example, that ops gets 403 on /api/admin/review and member gets 403 on /api/team.

Where roles come from

appRole — the platform operations role — is always read from the live Clerk JWT claim app_role, never from the database snapshot (src/lib/auth/clerk-session.ts:252-259). A demoted operator loses access on the next token refresh rather than waiting on webhook delivery.

tenantRole and tenantTitle are database-authoritative from tenant_memberships. An unrecognised Clerk organisation role falls closed to member (src/lib/auth/clerk-session.ts:209-217); a comment records that defaulting to owner was a fail-open gate.

The full surface-to-role matrix lives in Route surfaces, proxy and auth gates. For how a tenant is identified and separated, see Tenant scoping and isolation.

Content Security Policy

The CSP is assembled per request in src/proxy.ts:225-271 and set on both the request headers and the response.

default-src 'self'
script-src 'self' 'nonce-<nonce>' 'strict-dynamic' 'unsafe-eval' <clerk-frontend-api> https://maps.googleapis.com https://botid.vercel.com
script-src-attr 'none'
style-src 'self' 'unsafe-inline'
img-src 'self' blob: data: https: https://img.clerk.com
font-src 'self' data:
connect-src 'self' <clerk-frontend-api> https://clerk-telemetry.com https://*.clerk-telemetry.com https://maps.googleapis.com https://img.clerk.com https://images.clerkstage.dev
frame-src 'self' https://challenges.cloudflare.com
worker-src 'self' blob:
manifest-src 'self'
media-src 'self' blob: data:
object-src 'none'
base-uri 'self'
form-action 'self'
frame-ancestors 'none'

Three origins are appended to connect-src conditionally: the Supabase HTTPS and WSS origins derived from NEXT_PUBLIC_SUPABASE_URL (src/proxy.ts:100-113), the Sentry ingest origin derived from NEXT_PUBLIC_SENTRY_DSN (src/proxy.ts:120-130), and ws: wss: in development. The Sentry origin is derived rather than hardcoded so it tracks the configured region; without it the browser SDK's envelopes are refused and it buffers and retries.

<clerk-frontend-api> is https://clerk.ermisai.com in production and https://able-snake-88.clerk.accounts.dev otherwise (src/proxy.ts:95-98).

Two nonce modes

The 14 cacheable marketing pages get a deployment-stable nonce, base64("ermis-marketing-" + VERCEL_DEPLOYMENT_ID) (src/proxy.ts:89-91). Every other route gets a fresh base64(crypto.randomUUID()) per request (src/proxy.ts:221-224).

The reason is mechanical: those pages are served from the Vercel edge cache, so their HTML body is byte-identical across requests. A per-request nonce would never match the cached body and every script would be blocked. The in-code justification for accepting a shared nonce is that these pages are input-free with no injection sink, and strict-dynamic still applies. The value rotates on every deploy.

Do not describe the marketing-page nonce as per-request. It is shared across every visitor of a deployment, by design.

The nonce reaches React through the x-nonce request header, read in src/app/layout.tsx:63 and passed to <ClerkAppProvider nonce={nonce}>.

Two acknowledged weaknesses

Both are annotated in-code as GA-hardening (SEC-2):

  • 'unsafe-eval' in script-src (src/proxy.ts:232-235) — described as a defence-in-depth concession commonly required by a dev or runtime dependency, to be audited and dropped before GA. The nonce plus strict-dynamic remain the primary XSS control.
  • 'unsafe-inline' in style-src (src/proxy.ts:241-243) — permits inline style injection, to be moved to hashed or nonced styles before GA.

Neither is an oversight and neither should be described as resolved.

Image optimiser

The Next.js image optimiser carries its own policy, default-src 'self'; script-src 'none'; sandbox; (next.config.ts:223), with dangerouslyAllowSVG: true and dangerouslyAllowLocalIP: false.

Static response headers

Applied to /:path* from next.config.ts:33-48:

HeaderValue
X-Content-Type-Optionsnosniff
X-Frame-OptionsDENY
Referrer-Policyorigin-when-cross-origin
X-DNS-Prefetch-Controlon
Strict-Transport-Securitymax-age=31536000; includeSubDomains; preload
Permissions-Policycamera=(), microphone=(), geolocation=(self)

X-XSS-Protection was deliberately removed — the comment at next.config.ts:36-37 records that it is deprecated, ignored by modern browsers, and can introduce vulnerabilities in old ones. poweredByHeader: false (next.config.ts:101). /api/:path* additionally gets Cache-Control: no-store, max-age=0 (next.config.ts:78), with /api/sources/icon excepted.

Permissive CORS headers (Access-Control-Allow-Origin: *) exist but are gated on isDev and never ship to production (next.config.ts:50-61).

The SSRF network guard

ErmisAI fetches URLs supplied by tenants: RSS feeds, source icons, outbound webhook endpoints. One shared classifier backs all of them.

The blocklist

src/lib/sources/network-guard.ts seeds Node's BlockList from the IANA special-purpose registries.

IPv4: 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.88.99.0/24, 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4.

IPv6: ::/128, ::1/128, fc00::/7, fe80::/10, fec0::/10, ff00::/8, 2001:db8::/32.

normalizeIpAddress() strips the ::ffff: IPv4-mapped prefix before checking, so a mapped private address cannot slip through as an IPv6 literal.

isPrivateOrReservedIpAddress() returns true — blocked — for any input that is not a valid IP (network-guard.ts:92). The default is fail-closed. Preserve that behaviour if you refactor the function.

Per-hop revalidation and socket pinning

Validation alone is not enough: a hostname can resolve to a public address at check time and a private one at connect time. Two mechanisms close that window.

fetchValidatedCandidate() in src/lib/sources/feed-validation.ts:389 uses redirect: 'manual' and revalidates every hop against the blocklist, capped at MAX_FEED_VALIDATION_REDIRECTS = 4. redirect: 'follow' would chase a 30x into an internal address with no per-hop check.

createPinnedNetworkDispatcher() in src/lib/sources/source-icon-resolver.ts:157 builds an undici Agent whose lookup returns only the pre-validated address, so the socket dials exactly the IP that passed validation. The TLS servername stays the original hostname, so certificate validation is unaffected, and the dispatcher re-rejects private addresses at dial time.

Consumers

ModuleEntry pointExtra rules
src/lib/sources/feed-validation.tsassertPublicNetworkTarget() (line 321)Rejects non-http(s) schemes, embedded credentials, loopback hostnames, private IP literals, and hostnames resolving to any private address. 8-second timeout, max 14 candidate URLs
src/lib/sources/source-icon-resolver.tsassertPublicNetworkUrl() (line 85)Pinned dispatcher
src/lib/platform/integrations/webhooks.tsvalidateWebhookEndpoint() (line 64)Forces HTTPS in production; HTTP allowed only for loopback in development; WEBHOOK_DELIVERY_TIMEOUT_MS = 10_000
src/lib/services/rss-aggregation.tslines 76-77Same pinned-dispatcher pattern for feed fetching

Loopback and private targets are permitted only when NODE_ENV !== 'production'. Every check branches on that.

These paths call undici's own fetch, not Node's global fetch. A pinned dispatcher Agent from the installed undici is rejected by Node's bundled undici with UND_ERR_INVALID_ARG, which surfaces as "fetch failed" on every validation (feed-validation.ts:400-403). If you add a guarded fetch path, import undiciFetch.

Coverage lives in tests/public-network-guard.vitest.ts: cloud metadata (169.254.169.254), loopback, private ranges, non-HTTP schemes, and embedded credentials.

Because the socket is pinned, a legitimate endpoint behind split-horizon DNS or a rapidly rotating load balancer can be rejected. That is a known trade-off, covered from the integrator's side in Outbound webhooks.

Rate limiting

src/lib/api/rate-limit.ts uses Upstash @upstash/ratelimit sliding windows on the shared Redis instance, key prefix ermis:rl:<scope>:<key>, analytics: false. Both an IP limiter and — when a userId is passed — a user limiter run. The client IP is taken from the first non-empty of x-vercel-forwarded-for, x-forwarded-for, x-real-ip, cf-connecting-ip.

Limiter keyPer IPPer userApplied in
ai:chat20 / 60s30 / 60s/api/stories/[storyId]/chat
ai:completion30 / 60s60 / 60s/api/stories/[storyId]/completion
ai:draft-compose10 / 60s15 / 60s/api/stories/[storyId]/draft/compose
integration:source-validate20 / 60s30 / 60s/api/newsroom/profile/custom-sources, .../validate
integration:webhook-test10 / 60s15 / 60s/api/integrations/webhook/test
integration:m2m60 / 60s120 / 60s/api/integrations/wordpress/disconnect, .../connect/exchange, the shared CMS export handler
team:invite10 / 60s20 / 60s/api/team/invite
account:delete5 / 3600s3 / 3600s/api/account
email:unsubscribe10 / 60s10 / 60s/unsubscribe

account:delete is deliberately an order of magnitude tighter: it is a one-shot destructive action, so anything beyond a handful of attempts per hour from one address is treated as abuse. email:unsubscribe is unauthenticated by design (RFC 8058 one-click) and any well-formed token is persisted forever, so the cap exists to bound junk-row insertion.

Two limiter keys, ai:global and ai:compose, are declared in the config maps but have no call sites. Do not document them as active.

It fails open

enforceEdgeRateLimit returns "allowed" when the Redis env vars are absent, and again when a configured Redis throws (rate-limit.ts:159-160, 203-212). The file's rationale: edge burst limiting is abuse prevention, not billing, so a Redis blip must not 500 legitimate traffic. The fail-closed control is the per-tenant AI guardrail assertAiInvocationAllowed, described in AI usage accounting, guardrails and metering.

Never describe rate limiting here as guaranteed throttling.

Rejection shape

buildRateLimitErrorResponse returns HTTP 429 with body { error, code: 'rate_limited', scope, retryAfterSeconds } and headers Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. The message is "Too many requests from this network. Please retry shortly." for the IP scope and "You have sent too many AI requests. Please wait and retry." for the user scope.

Secrets and credentials at rest

Tenant API keys

A raw key is ermis_ plus 48 hex characters — `ermis_${randomBytes(24).toString('hex')}` (src/lib/platform/integrations/keys-and-wordpress.ts:350). Only sha256(rawKey) hex is stored (hashApiKeyValue, line 32-34), alongside a display mask ermis_••••••••<last4> (maskApiKeyValue, line 36-38). toPublicApiKeyRecord returns the mask and never the key. The raw value is shown exactly once, at creation.

Outbound webhook secrets

src/lib/security/secret-encryption.ts implements AES-256-GCM envelope encryption in the format enc:v1:<iv-b64>:<authTag-b64>:<ciphertext-b64> with a 12-byte IV. The key is read from WEBHOOK_SECRET_ENCRYPTION_KEY and must be exactly 32 bytes, encoded as base64 or 64 hex characters; anything else throws.

When WEBHOOK_SECRET_ENCRYPTION_KEY is unset, encryptSecret() is a silent no-op that returns the plaintext (secret-encryption.ts:55-60). Nothing warns. Do not claim "secrets are encrypted at rest" unconditionally — it is true only when the key is configured.

Scope is narrow by design. The only call sites are src/lib/db/integrations-repository.ts:437 (encrypt on write) and :95 (decrypt on read), so the module covers tenant outbound-webhook HMAC secrets and nothing else. decryptSecret() passes non-prefixed legacy values through unchanged, and throws only when it encounters an enc:v1: value with no key configured — so removing the key after enabling it breaks reads. Covered by tests/secret-encryption.vitest.ts.

Email suppression tokens

src/lib/email/suppression.ts stores only HMAC-SHA256(EMAIL_UNSUBSCRIBE_SECRET, lowercased-address) base64url-encoded — no plaintext address is retained. In production or on Vercel, an unset secret throws; local development falls back to a literal development secret.

Rotating EMAIL_UNSUBSCRIBE_SECRET without moving the old value to EMAIL_UNSUBSCRIBE_SECRET_PREVIOUS makes every existing suppression record unmatchable — silently re-consenting everyone who unsubscribed. Lookups check both secrets; new records always use the current one.

Webhook payload archives

Clerk and Polar webhook bodies contain PII (emails, names, billing identifiers). uploadPrivateArchiveObject() (src/lib/storage/object-storage.ts:281-319) writes them to a private location: Supabase Storage with visibility: 'private', or a dedicated private Vercel Blob store reached through BLOB_ARCHIVE_READ_WRITE_TOKEN with access: 'private' and addRandomSuffix: true, under an archive/ prefix, with the returned url nulled out so no fetchable location is ever persisted.

A public Blob store rejects private puts — store access is fixed at store creation — which is why the split exists. deletePrivateArchiveObject() routes by the recorded provider and bucket rather than the currently configured environment, so rows written before a cutover still delete from the store they actually live on.

Without BLOB_ARCHIVE_READ_WRITE_TOKEN, the archive is not written at all and the caller inlines the payload into its database row. The 30-day retention prune only deletes rows where payload_storage_path IS NOT NULL (src/lib/db/clerk-user-repository.ts:86, src/lib/db/billing-repository.ts:205), so those inlined rows are never pruned and PII accumulates indefinitely. This is the single most consequential misconfiguration on this page.

Machine-to-machine authentication

Cron bearer

isAuthorizedCronRequest() (src/lib/api/cron-auth.ts:46-65) compares Authorization: Bearer ${CRON_SECRET} using timingSafeEqual, after a length check that is not a timing oracle because the expected length is fixed by the env value. It returns false when CRON_SECRET is unset, so a missing env var can never open the endpoint.

runGuardedCronHandler() turns an unexpected throw into a typed 500 routed to Sentry, and reportCronDrainFailures() raises a Sentry error when a drain reports more than zero failures. Details in Scheduled jobs and the queue subsystem.

Queue message HMAC

@vercel/queue's handleCallback performs no inbound verification — it parses any well-formed CloudEvent POST and invokes the handler. The consumer routes are allowlisted past Clerk so Vercel can deliver, which would otherwise leave them anonymously callable and a crafted POST could trigger a full AI aggregation run.

The payload therefore carries its own HMAC-SHA256 signature over a canonical, sorted-key JSON of every field except the signature, keyed by ERMIS_QUEUE_SIGNING_SECRET (src/lib/platform/queues-adapter.ts:138-182). verifyRssMessageSignature() fails closed when the secret is unset; publishRssMessage() throws rather than emitting a message no consumer could accept.

Inbound webhook signatures

Clerk uses verifyWebhook with CLERK_WEBHOOK_SIGNING_SECRET; Polar uses POLAR_WEBHOOK_SECRET; Resend implements the Svix scheme by hand with a ±5-minute timestamp tolerance. All three return 503 when their secret is missing, and all three deduplicate on an event-log row. Full treatment in Inbound webhooks: Clerk, Polar and Resend.

Outbound webhook signature

Deliveries carry X-Ermis-Signature: sha256=<hex> where the HMAC-SHA256 input is ${timestamp}.${body} (src/lib/platform/integrations/webhooks.ts:55-57), alongside X-Ermis-Event, X-Ermis-Delivery-Id, and X-Ermis-Timestamp. Consumer-side verification is documented in Outbound webhooks.

Bot protection

Vercel BotID is initialised in src/instrumentation-client.ts via initBotId({ protect: [...] }). Running it from the nonced, bundled client entry means the challenge is injected by already-trusted code, so strict-dynamic authorises it — replacing a legacy head component whose inline script needed a hash that drifted between dev and prod builds. Bot protection runs without a telemetry-consent gate because it is a security control resting on legitimate interest. https://botid.vercel.com is allowlisted in script-src, and withBotId() wraps the build config (next.config.ts:441).

Server-side, verifyHumanRequest() (src/lib/api/bot-guard.ts) calls checkBotId(), passes when isHuman || bypassed, passes verified good bots unless allowVerifiedBots: false, and otherwise returns 403 { error: 'Bot traffic blocked', code: 'botid_rejected' }.

The client protect list and the server call sites do not match, in both directions.

initBotId({ protect }) lists GET /api/stories and POST /api/stories/queue, but neither src/app/api/stories/route.ts nor src/app/api/stories/queue/route.ts calls verifyHumanRequest or checkBotId. The client injects a challenge; the server never checks it. Do not document those two endpoints as bot-protected.

Conversely POST /api/newsroom/profile/custom-sources calls verifyHumanRequest({ deepAnalysis: true }) server-side but is absent from the client protect list.

The only three routes with server-side verification are /api/integrations/webhook/test, /api/newsroom/profile/custom-sources, and /api/newsroom/profile/custom-sources/validate. /api/integrations/wordpress/stories is excluded on purpose — it is a machine-to-machine endpoint authenticated by tenant API key, and BotID would reject every legitimate caller.

Client-side Sentry starts only after the visitor accepts cookies. src/instrumentation-client.ts:71-86 checks hasTelemetryConsent() on load and subscribes to subscribeCookieConsentChange, so toggling consent takes effect without a reload; withdrawal calls Sentry.getClient()?.close(). Server-side Sentry is unaffected — it stores nothing on the device.

AI prompt and response content never reaches telemetry. recordInputs and recordOutputs are false at every AI call site: src/app/api/stories/[storyId]/chat/route.ts:687-688, .../completion/route.ts:355-356, src/lib/services/story-compose.ts:840-841, and src/lib/services/rss-aggregation.ts:2600-2601, 3014-3015.

The enrichSpan hook attaches identifiers only — ermis.stage, ermis.surface, ermis.billing_scope, optional ermis.workflow_mode, ermis.tenant_scope_id, ermis.story_id, ermis.route_key. The tenant scope id passes through redactTenantScopeId because personal scopes embed the Clerk user id (src/instrumentation.ts:54, 71).

Production traces are sampled at 10%; everywhere else at 100%. The built-in 'VercelAI' Sentry integration is filtered out (src/instrumentation.ts:112) so the @ai-sdk/otel bridge is the single gen_ai span source. Sentry is entirely inert when no DSN is set.

Deliberate disclosures

GET /api/health is anonymous by design and reports: the AI guardrails mode, whether AI and signups are paused, whether waitlist mode is on, the platform daily and monthly spend caps in cents, and the names of missing launch-critical environment variables. No values are ever returned. Treat this as an intentional, documented disclosure rather than a leak, and keep it that way when adding fields. See Deploying to production.

What ErmisAI does not claim

The published /security page states five non-claims verbatim. They are accurate. Do not upgrade them anywhere, including in a customer questionnaire.

  • "No certification unless published." — no SOC 2, ISO 27001, or equivalent at this stage.
  • "No public bug bounty." — reports are reviewed; rewards are not promised.
  • "No uptime SLA unless contracted." — Free, Plus, and Pro carry no uptime guarantee; Enterprise contracts may define one.
  • "No prevention of AI editorial errors." — editorial review before publication is a design assumption, not an optional step.
  • "No substitute for publisher rights review."

Known gaps

Beyond the callouts above, four gaps are worth stating plainly:

  1. No security.txt. There is no /.well-known/security.txt route. The only .well-known route in the tree is src/app/.well-known/vercel/flags/route.ts.
  2. No downloadable DPA. All legal copy says the DPA is available before contract, obtained by emailing the privacy contact. There is no DPA artefact, download link, or self-serve request form in the repository.
  3. No DSAR self-service beyond account deletion. There is no access, export, rectification, or portability endpoint or screen. The /gdpr page directs subjects to email; the erasure subsystem is deletion only. See Your account and your data and GDPR erasure runbook.
  4. Isolation is application-level. Tenant separation is tenant_scope_id filtering in application queries plus per-route gates. There is no row-level security and no database-level ACL. See Tenant scoping and isolation.

Reporting a vulnerability

The /security page renders a mailto: built from NEXT_PUBLIC_ERMIS_SECURITY_EMAIL. When that variable is unset the page shows the literal fallback "No security inbox published yet." instead of a link, so the live address is whatever https://ermisai.com/security currently renders. Whether the variable is populated in a given environment is reported by GET /api/health under config.missing.

Tällä sivulla