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.
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 |
backend/app/core/security.py — inspect_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.
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.
Enforced server-side, in backend/app/core/quota.py, in this order, so the
cheapest check runs first:
- Per-session —
max_messages_per_session = 10. - Per-IP-per-minute —
max_requests_per_ip_per_minute = 6. - Per-IP-per-day —
max_requests_per_ip_per_day = 60. - Global-per-day —
max_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.
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_ORIGINSis scoped to the exact production origin —https://destivano.github.io, never*.backend/app/main.py'sCORSMiddlewarereflects only that origin inaccess-control-allow-origin; a request with any otherOriginheader 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, OPTIONSandContent-Typeonly. - This governs browser access, not server-to-server access — anyone can
still
curlthe 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.
- No accounts, no persistent conversation storage. Session identifiers are
client-generated and kept only in the visitor's browser (in memory, and in
sessionStorageso 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 text —
backend/app/core/logging.pylogs 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.
- The Groq API key lives only in backend configuration (Lambda environment
variable /
.envlocally) and is never sent to, or read by, the frontend. The frontend only ever calls this project's own/api/chat. /docsand/openapi.jsonare disabled whenenvironment=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 inbackend/app/main.py.
- 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.