Skip to content

Architecture

Security and privacy

5 min readUpdated Verified against 05086f5 on 2026-09-27

Page actions

ChatGPT and Claude are third-party services. The link gives them this page's public address and a short instruction. This site records nothing.

The defences at each layer — browser headers, a read-only API with bounded input, an immutable database, gated CI — what is and is not collected about readers, and the known gaps.

The system has an unusually small attack surface, by design. It has no accounts, no logins, no user data, no request bodies and no writes: the API answers GET requests from a database it opens read-only. What it must protect is the integrity of the text and the privacy of the people reading it. This page lists the defences at each layer, what is collected, and the gaps that are known and open. To report a vulnerability, follow SECURITY.md (email, not a public issue).

Defences at each layer between a reader and the databaseIn the browser, the site sends a content security policy, frame denial, no referrer, HSTS and a restrictive permissions policy, and escapes all API text. The request reaches the API on the same origin over HTTPS, so there is no CORS. The API accepts only GET, bounds every parameter, cleans full-text queries, parameterises all SQL and returns a generic body for any internal error. SQLite is opened read-only, immutable and query-only, and each context can read only the tables it declares. Beside the request path, CI scans for secrets, audits dependencies and deploys only after the required checks pass.HTTPS, same origin

Browser: CSP, HSTS,frame denial,no referrer, escaping

API: GET only,bounded input,clean full-text query,parameterised SQL

SQLite: read-only,immutable, query-only,declared tables

CI: secret scan,npm audit, deploysgated by commit

Defences at each layer between a reader and the databaseIn the browser, the site sends a content security policy, frame denial, no referrer, HSTS and a restrictive permissions policy, and escapes all API text. The request reaches the API on the same origin over HTTPS, so there is no CORS. The API accepts only GET, bounds every parameter, cleans full-text queries, parameterises all SQL and returns a generic body for any internal error. SQLite is opened read-only, immutable and query-only, and each context can read only the tables it declares. Beside the request path, CI scans for secrets, audits dependencies and deploys only after the required checks pass.HTTPS, same origin

Browser: CSP, HSTS,frame denial,no referrer, escaping

API: GET only,bounded input,clean full-text query,parameterised SQL

SQLite: read-only,immutable, query-only,declared tables

CI: secret scan,npm audit, deploysgated by commit

Defences at each layer between a reader and the database

The API

The same handler serves every host (serve.py, and each Vercel function subclasses it).

DefenceHowWhere
Read-only by constructiononly do_GET and do_HEAD exist, so there are no request bodiesserve.py
A database that cannot be writtenmode=ro&immutable=1 and PRAGMA query_only=ON; the image makes the file 0444sggs/core.py, webapp/Dockerfile
Least privilege per contexteach context declares its TABLES; a test runs every route under an SQLite authorizer that refuses anything else, and a function will not load if its slice lacks a declared tablewebapp/tests/test_declared_tables.py, tools/build_api_functions.py
Bounded inputevery integer through _int() (at most 12 characters, clamped to a range); search q at most 300 characters, limit at most 200; a verify claim at most 600 characters; id lists at most 300sggs/core.py, sggs/search.py, sggs/verification.py
No injectionevery value bound with ?; the only built SQL strings are constant columns and ? lists; full-text input loses " and * and each token is quoted; FTS columns come from an allowlistsggs/search.py, invariants
No path traversalstatic paths are resolved with realpath and must stay under the static root; %-encoded input is never decodedserve.py _resolve_static
No leaks on errora bad parameter is a 400 with a message the code wrote; anything unexpected is a 500 with the fixed body {"error": "internal server error"} and the traceback only in the logserve.py
Slow clients15-second socket timeout; at most 48 worker threads and a backlog of 128serve.py
Response headersX-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer, HSTS, X-Request-Id, X-Service on every responseserve.py _sec_headers

The browser

Both sites send their headers from vercel.json:

  • Content Security Policy. default-src 'self', frame-ancestors 'none', object-src 'none', base-uri 'self'. The website allows 'unsafe-inline' for scripts and styles (inline theme and handler code) and one outside origin, the newsletter provider, for connect-src and form-action. The wiki allows inline scripts only by SHA-256 hash, checked in CI by npm run check:csp.
  • Transport and isolation. HSTS for two years with includeSubDomains; preload, Cross-Origin-Opener-Policy: same-origin, and a Permissions-Policy that turns off camera, microphone and payment (the website keeps geolocation for its own origin, for the raag clock’s solar mode).
  • Escaping. All scripture and API text is escaped by esc() before it reaches the page (the website).
  • Tests. frontend/e2e/csp.spec.ts loads every route under the enforced policy and expects zero violations; the wiki’s e2e server sends its production headers to every test.

CI and the supply chain

  • Deploys only through CI. Production and staging deploy only from their workflows, after the required checks pass on the exact commit, and are verified by the commit the running API reports (ADR-0005). The platforms’ own Git deployments are off.
  • Secrets live in the production and staging GitHub Environments (and production-approval for an optional reviewer). Values are never typed into chat, logs or files; one that is pasted anywhere is treated as leaked and rotated (delivery).
  • Least-privilege workflows. Every workflow declares permissions:; most are contents: read, with issues: write only where a workflow files an issue, and contents: write only for tagging a release.
  • Scans (security.yml): gitleaks on every change (the required secrets check); npm audit --audit-level=high for the website and the wiki; bandit, semgrep and actionlint. Dependabot proposes npm, Actions and Docker updates weekly.
  • The dataset is fetched only by pin. fetch_dataset.py downloads the exact object named in dataset.lock.json and checks its SHA-256 and size; build_api_functions.py --verify-output refuses a build that would publish the database or the API source as static files.

Privacy

What is and is not collected, as the public privacy policy states it (every statement there is checked against the code):

  • The app collects nothing: no accounts, no network connections, no analytics or third-party SDKs. Its App Store label is “Data Not Collected”. Location for the raag clock is rounded to about 1 km and stays on the device.
  • The Knowledge Base pages load no analytics and set no cookies. Preferences, reading positions, pins and a rounded location (about 1 km) stay in the browser’s storage.
  • The Gurbani Soul pages on gurbanisoul.com use Vercel Web Analytics: cookieless totals of page, referrer, country, browser, OS and device type, with a visitor hash discarded daily.
  • The API log is one JSON line per request with the method, the path, the status, the duration and the request id. It never records the query string, the IP address or the user agent; a test fails if a query string appears (ADR-0009). Hosting providers keep their own standard logs.
  • The newsletter, when enabled, sends only an email address to Buttondown, with double opt-in (newsletter runbook).

Known gaps

These are open and tracked here so nobody mistakes them for guarantees:

  • Static analysis does not block. bandit runs with || true and semgrep with continue-on-error; only the secret scan is a required check. npm audit fails its job but is not in the branch rulesets.
  • Not every Action is pinned to a SHA. The deploy and docs workflows are; web-ci, security, e2e and several others use version tags. gitleaks is downloaded without a checksum check.
  • The website’s CSP allows inline script ('unsafe-inline'), unlike the wiki’s hash-based policy.
  • No application rate limiting. The API relies on its bounded input and the platform’s own protection.
  • The API container runs as the image’s default user (no USER line in the Dockerfile). Production runs as Vercel functions; the container is the Render standby.
  • frontend/e2e/headers.spec.ts never runs in CI. It needs SMOKE_BASE_URL, which no workflow sets, so nothing checks the website’s deployed headers.
  • No written threat model for the web and API.

New risks go into the risk register.