Skip to content

Security: Destivano/personal_assistant

Security

docs/security.md

Security

What this system protects against, and how. Scope: a public, unauthenticated assistant answering questions about one person's career from a fixed, author-controlled corpus.

Threat model

There are no user accounts, no persistent per-visitor data, and no untrusted documents in the corpus — everything the assistant can retrieve comes from data/*.json, written by the portfolio owner. That removes an entire class of RAG risk (indirect prompt injection via ingested third-party content). What remains:

Threat Concern
Prompt injection via the chat input Visitor tries to override system instructions
Off-purpose use Visitor tries to use the assistant as a free general-purpose LLM
Cost abuse Visitor (or a script) tries to run the LLM bill up
Data exposure A non-public entry in data/ leaking through the API or the corpus
Visitor privacy IP addresses being retained or logged in the clear

Application-layer guards

backend/app/core/security.pyinspect_question() runs before quota consumption and before retrieval, on every request:

  • Injection patterns: "ignore previous instructions", "reveal your system prompt", "you are now...", "pretend to be...", <system>-style tag injection, DAN/jailbreak phrasing. Structural, not keyword-based — the corpus legitimately contains "prompt engineering" and "LLM systems", so single-keyword matching would block real questions.
  • Off-purpose misuse patterns: "write me a poem", "generate a SQL query", "translate the following" — requests to use the endpoint as a free-form generator rather than a question-answerer about one person.
  • Suspicious formatting: more than 20 newlines in a single question, on top of the hard length cap (max_question_chars = 1500) enforced at the Pydantic model.

A blocked request is refused before quota is consumed and before retrieval or the LLM is touched — an attacker cannot spend a legitimate visitor's quota by forcing refusals, and blocking never costs money.

Measured on the adversarial section of the golden set: 11/11 handled correctly — 8 injection/misuse attempts blocked, 3 false-premise questions ("confirm he has a PhD from MIT") correctly let through so the assistant can refute them from the corpus rather than refusing to engage. 8 legitimate questions using words like "system" and "prompt" are asserted not blocked in tests/test_quota_and_security.py, because a guard with false positives on real questions is not a passing guard. See evaluation.md.

This is a cheap first filter, not the only defence — the system prompt and the citation/grounding checks (below) are what actually keep answers factual even if a clever phrasing slips past the regex.

Grounding and hallucination controls

Layered, and each layer is unit-tested rather than trusted on faith — see evaluation.md § Hallucination controls for the full list and the tests that back it. In short: content flagged public: false is excluded from build artifacts (physically absent, not filtered at request time); the off-topic gate refuses questions the corpus cannot support (15/15 on the golden set); the system prompt requires every factual sentence to carry a [Sn] marker; markers outside the supplied passage range are dropped as fabrications; and a substantial answer with no valid citation is discarded and replaced with "I don't have that information" rather than shown to the visitor.

Cost / abuse controls

Enforced server-side, in backend/app/core/quota.py, in this order, so the cheapest check runs first:

  1. Per-sessionmax_messages_per_session = 10.
  2. Per-IP-per-minutemax_requests_per_ip_per_minute = 6.
  3. Per-IP-per-daymax_requests_per_ip_per_day = 60.
  4. Global-per-daymax_llm_calls_per_day_global = 500, a hard ceiling independent of any single visitor, after which the assistant degrades to retrieval-only answers instead of calling the LLM.

Counters live in DynamoDB with TTL-based expiry in production (MemoryQuotaStore locally/in tests); increments are atomic (ADD), so concurrent requests cannot race past a limit. A blocked request returns 429 with a retry_after_seconds hint and — as above — never touches the LLM or the counters it would otherwise consume. Full cost reasoning and the dollar amounts these limits bound: cost-control.md.

Additional caps: max_question_chars = 1500 (input), max_output_tokens = 700 (output), llm_timeout_seconds = 20.0 — a hung upstream call cannot hold a Lambda invocation open indefinitely.

CORS — a real trust boundary, not a convenience

The portfolio (https://destivano.github.io, GitHub Pages) and the backend (an AWS Lambda Function URL) are two different origins by construction — see ADR 0008. An earlier iteration served both from one CloudFront distribution, which made CORS a non-issue; that's no longer the topology, so CORS is now an actual enforced boundary, not decoration:

  • CORS_ORIGINS is scoped to the exact production originhttps://destivano.github.io, never *. backend/app/main.py's CORSMiddleware reflects only that origin in access-control-allow-origin; a request with any other Origin header gets no CORS headers back, and the browser (not the server) refuses to expose the response to the calling page.
  • allow_credentials=False — the assistant doesn't use cookies or any credentialed request mode, so there is nothing for a cross-site request to forge.
  • Methods and headers are minimal: GET, POST, OPTIONS and Content-Type only.
  • This governs browser access, not server-to-server access — anyone can still curl the Function URL directly from a script. That's intentional and unavoidable for a public API with no auth (§ Deliberately out of scope); CORS's job here is only to stop an arbitrary web page from making credentialless requests on a visitor's behalf, not to gate the API itself. Request-level abuse is what the quota layer below is for.

Visitor privacy

  • No accounts, no persistent conversation storage. Session identifiers are client-generated and kept only in the visitor's browser (in memory, and in sessionStorage so a reload doesn't lose the conversation) — used only as a quota key, by design, per the product decision to keep no persistent visitor conversations (see project decisions). Closing the tab ends it.
  • IP addresses are never stored or logged in the clear. client_key() salts and SHA-256-hashes the IP (rate_limit_salt, truncated to 32 hex chars) before it is used as a DynamoDB key. The salt means the stored digests cannot be reversed by hashing a candidate IP list without also knowing it.
  • Structured logs never contain the API key, raw IPs, or message textbackend/app/core/logging.py logs method, path, status, duration, and route taken, not request or response bodies. Query strings are explicitly excluded from the request log line because they could carry visitor question text.

Secrets

  • The Groq API key lives only in backend configuration (Lambda environment variable / .env locally) and is never sent to, or read by, the frontend. The frontend only ever calls this project's own /api/chat.
  • /docs and /openapi.json are disabled when environment=production.
  • Error responses use a fixed envelope ({"error": {"code", "message"}, "request_id"}) and never echo back raw exception text or the rejected request body — see the exception handlers in backend/app/main.py.

Deliberately out of scope

  • DDoS / volumetric protection. Lambda's own scaling limits provide baseline resilience; nothing bespoke is layered on for a portfolio site. Reserved concurrency (5) bounds worst-case Lambda spend regardless, and the portfolio page itself is on GitHub Pages, entirely unaffected by anything that happens to the backend.
  • WAF. Not justified at this traffic level or threat profile; would also be a recurring cost on a project whose entire premise is near-zero spend.
  • Authentication. The assistant is intentionally public and read-only; there is nothing behind it to authenticate to.

There aren't any published security advisories