How this site is secured
This site is also a security project. I build and run it like a small production service: hardened HTTP headers, a security pipeline on every change, an AI assistant designed against the OWASP Top 10 for LLM Applications, and privacy by design.
This page describes the controls and how they fit together. It leaves out tuning values such as exact limits and filter rules on purpose.
Architecture
- Visitor to Vercel (trust boundary 1): every request arrives over HTTPS. Vercel adds the eight security headers and the Content Security Policy to every response.
- Pages: the pages are static and contain no visitor data.
- API routes:
/api/chat(the assistant),/api/contact(the contact form) and/api/collect(visitor statistics). Each checks the origin, content type, size and shape of a request before it does anything else. - Site to third parties (trust boundary 2): Upstash holds rate-limit counters and statistics, the AI providers receive questions and public text to answer them, and Resend delivers contact-form messages. They are called from the server, so none of them receives a visitor's IP address; rate limits use a salted hash of it.
HTTP security headers
Every route, including the API routes and the CV file, sends these eight headers.
| Header | Protects against |
|---|---|
| Content-Security-Policydefault-src 'self'; frame-ancestors 'none'; object-src 'none' … | Cross-site scripting and data injection: the browser loads scripts, styles, images and connections only from this site. |
| Strict-Transport-Securitymax-age=63072000; includeSubDomains | Downgrade to plain HTTP and cookie or session hijacking on the network path. |
| X-Frame-OptionsDENY | Clickjacking: no other site can show these pages in a frame. |
| X-Content-Type-Optionsnosniff | MIME sniffing: a file is never run as a different content type. |
| Referrer-Policystrict-origin-when-cross-origin | Leaking full URLs to other sites: they receive only the origin. |
| Permissions-Policycamera=(), microphone=(), geolocation=() | Misuse of device features: camera, microphone and location are switched off for the site. |
| Cross-Origin-Opener-Policysame-origin | Cross-window attacks: pages opened from other sites get no handle to this window. |
| Cross-Origin-Resource-Policysame-origin | Other sites embedding or reading these files in a browser (cross-origin data leaks). |
The trade-off in the CSP. Scripts are allowed only from this site, but the policy still needs 'unsafe-inline' for scripts: Next.js puts small inline scripts into static pages, and the theme switcher adds one to avoid a flash of the wrong theme. The site scores A on securityheaders.com, not A+. A+ would need a nonce-based CSP, which makes every page render per request instead of being served as a static file. I chose static pages and accept this as a known trade-off; the rest of the policy still blocks framing, plugins, third-party scripts and connections to other sites.
Secure development pipeline
- Secret scanning. gitleaks scans the full git history of every branch on every push and daily, and a pre-commit hook scans each commit before it is made.
- Code scanning. Semgrep (JavaScript, TypeScript, React and secrets rules) fails on any finding. ESLint and the unit tests for the security controls run in the same workflow.
- Dependencies. npm audit fails on critical vulnerabilities, and Dependabot proposes updates every week.
- Hardened workflows. Every GitHub Action is pinned to a full commit SHA, the workflows get read-only access to the repository and need no secrets, and the gitleaks binary is checked against its published checksum.
- Live checks. After every production deploy and daily, a script checks the live site: all eight headers and the key CSP directives, the HSTS lifetime, the redirect to the canonical host, that security.txt is present and not about to expire, and that the published CV file matches its SHA-256 hash in the repository.
Why there is a custom gitleaks rule. This site started from a template whose git history contained a Gmail app password. gitleaks with its default rules reported nothing; a manual review found it. A Google app password is 16 lowercase letters with no fixed prefix, so the default rules cannot match it. I had the credential revoked, rebuilt the history, and wrote a custom rule that matches such a value only when it is assigned to a password-like setting: on the original commit the default rules found nothing and the custom rule found the leak, with no false positives in the repository. The rule now runs in CI and in the pre-commit hook.
Professor, the AI assistant
Professor answers questions about my work from the public content of this site. Its controls are mapped to the OWASP Top 10 for LLM Applications (2025).
| Risk | Controls |
|---|---|
| LLM01 Prompt Injection | Pre-checks on the question and the conversation history before any model call. Retrieved text and history are passed to the model as data and never treated as instructions. The model has no tools, so a successful injection can only change text, and that text still passes the output filter. |
| LLM02 Sensitive Information Disclosure | The knowledge contains only content that is already public, and it is scanned for personal data and secrets when it is built. The output filter removes phone numbers and any email address other than the public contact address. No conversation is stored. |
| LLM03 Supply Chain | No LLM SDK or framework: the providers are called directly over HTTPS. Model settings are validated on the server; an invalid setting disables that provider or the assistant instead of falling back to an unknown model. Dependencies and code are scanned in the pipeline below. |
| LLM04 Data and Model Poisoning | Knowledge is built only from my own public pages, CV and journal, and every change to it is reviewed and committed. Visitors cannot add anything to it. |
| LLM05 Improper Output Handling | Answers are plain text, never rendered as HTML. Links are clickable only when they point to an allow-listed site; other links and unsafe URL schemes are removed on the server. |
| LLM06 Excessive Agency | The model has no tools, functions, credentials or write access. It can only return text. |
| LLM07 System Prompt Leakage | Extraction attempts are refused before the model is called. A leak check stops answers that reveal the instructions. Refusals are fixed texts on the server: the model only signals the kind of reply, so it cannot change their wording. The instructions hold no secrets. |
| LLM08 Vector and Embedding Weaknesses | The vector index is read-only and holds public text only. When nothing relevant is retrieved, the model is not called and a fixed reply is sent. |
| LLM09 Misinformation | Answers come only from the public content found for the question, and the sources are shown under every answer. When the knowledge does not cover a question, the assistant says so instead of guessing. |
| LLM10 Unbounded Consumption | Per-visitor and global rate limits, daily caps, input and output length limits and timeouts. Model use has a daily cost limit, and paid usage is prepaid. If another provider fails, the next one answers. If the rate-limit store cannot be reached, the assistant fails closed. |
Before each release, an evaluation suite of normal questions, attack prompts and regression cases from earlier findings runs against a local server. Attack and regression cases must all pass.
Contact form
- Requests are accepted only from this site's own origin and only as JSON.
- The body size is capped before it is parsed, and every field is checked for type, length and format.
- A hidden honeypot field catches simple bots.
- Rate limits, keyed by a salted hash of the sender's IP address. The form fails closed when the limiter cannot be reached.
- Input is HTML-escaped in the email, and line breaks are removed from anything used in a mail header.
Privacy by design
- First-party visitor statistics without cookies or identifiers. IP addresses are never stored.
- Do Not Track and Global Privacy Control are respected, and visitors can turn counting off for their browser.
- Conversations with Professor are not stored by this site.
- I read the statistics only through a dashboard that runs locally on my own machine with read-only access. It is never deployed, and the pipeline checks that it is not in the build.
Details are in the privacy notice.
Responsible disclosure
If you find a vulnerability on this site, please report it to hello@shahali.dev, as described in security.txt. Please do not test in a way that affects other visitors. There is no bug bounty.
More on this work: Secure RAG Assistant in my projects, and the Security CI Pipeline described above.