- BYO-key transport - covered in detail in BYO-key flow.
- Input sanitization - block prompt-injection before it reaches the LLM.
- Output sanitization - block leakage on the way back.
- Render-time safety - markdown rendered without raw HTML, links with
rel="noopener noreferrer".
1. Input sanitization (sanitizeInput)
Located in src/lib/ai/sanitize-input.ts. Runs after Zod schema validation but before the provider call.
Unicode normalization first
Pattern matchers don’t see naïve UTF-16 -sanitizeInput calls .normalize('NFKC') and applies a homoglyph-fold (Cyrillic → Latin) before pattern matching. This kills the classic Cyrillic-о-instead-of-Latin-o bypass.
~35 blocking patterns
Roughly grouped:
A match returns
{ ok: false, reason } → route responds 400 blocked. The reason is never echoed verbatim to the LLM or to network logs that include user payloads.
Length cap
Already enforced at the schema:message is 1–8,000 chars. The body-size cap (16,384 bytes) is enforced earlier still, before JSON parse, so oversized payloads never touch the parser.
2. Output sanitization (sanitizeOutput)
Located in src/lib/ai/sanitize-output.ts. Runs after the provider returns but before the JSON response is built.
Four leakage checks:
- API-key shape. Strip anything matching common provider key prefixes (
sk-…,sk-ant-…,AIza…) plus high-entropy long strings. - System-prompt echo. Strip blocks the LLM might have copied verbatim from the system prompt.
- PII leak from the resume. Reduce risk of accidental phone-number / email harvesting (the bot intentionally shares the owner’s contact info if relevant - the check is for other PII the resume might contain about third parties).
- Internal error text. Drop stack-trace-looking strings.
500.
3. Markdown render-time safety
MessageBubble.tsx renders with:
react-markdown9 +remark-gfm4- No
rehype-raw- raw HTML embedded in model output is rendered as text, not parsed. This is the single most important XSS defense. - A
SafeLinkcomponent that overrides<a>to injectrel="noopener noreferrer"andtarget="_blank".
<img src=x onerror=alert(1)>, the user sees the literal string.
4. Per-bot rate limiting
src/lib/ai/rate-limit.ts enforces a 2-tier in-memory limiter:
- Short window - burst control (a few requests per few seconds)
- Long window - sustained-traffic control (a couple dozen per hour)
scope (which tier tripped) and resetAt (epoch ms). The limiter is in-process by default - fine for a single region; setting the Upstash Redis env vars switches it to shared state for multi-instance deployments.
Error responses never leak credentials
Every error envelope is a stable shape:<code> is one of: missing_llm_key, invalid_llm_key, invalid_json, validation_failed, bot_not_found, blocked, rate_limit, provider_rate_limit, provider_unavailable, request_too_large, unsupported_media_type.
The key is never present in the envelope, even when the cause is the key itself (invalid_llm_key).
Already shipped
- Redis-backed rate limit + circuit breaker (Upstash, with in-memory fallback).
- OAuth (Google, GitHub) + email verification.
- GDPR data-export and account deletion. See Export & delete.