160 lines
8.8 KiB
Markdown
160 lines
8.8 KiB
Markdown
# Security — Queue North Website
|
|
|
|
```
|
|
Status: Current
|
|
Owner: _null
|
|
Last reviewed: 2026-08-18
|
|
Governs: server/index.js, .env.example, Dockerfile, docker-compose.yml —
|
|
authentication, secret handling, data at rest and in transit
|
|
Review trigger: Any new secret, any new external service, any change to auth or storage
|
|
```
|
|
|
|
## What this protects, and from whom
|
|
|
|
This is a public marketing site with two write endpoints. It has no accounts, no
|
|
sessions and no privileged area, so the entire class of authorisation defect does
|
|
not exist here. What it does have is **other people's contact details**, arriving
|
|
unauthenticated from the open internet and landing in a single SQLite file.
|
|
|
|
The realistic adversaries, in the order they will actually turn up: a bot
|
|
spamming the contact form; somebody scraping the lead database if it is ever
|
|
exposed; a credential in the repository or the bundle; and the loss of the volume
|
|
the database lives in.
|
|
|
|
| Asset | Where it lives | What would it cost to lose |
|
|
| --- | --- | --- |
|
|
| Inbound leads and support requests | `/app/db/queuenorth.db`, a Docker named volume on nebula | **The whole point of the site.** These are prospective customers who filled in a form. There is currently no backup, so loss is total and silent |
|
|
| Zoho WebToLead form tokens | `zoho.md` on the operator's machine, and the deploy environment | Low. They are public-by-design identifiers, rendered in the browser by any Zoho web-to-lead form. Losing them means re-reading them out of Zoho |
|
|
| reCAPTCHA **secret** key | `zoho.md` and the deploy environment | Real. With it, an attacker can validate their own tokens and the form's only anti-abuse control is gone |
|
|
| Zoho OAuth refresh token (standby path) | deploy environment only | Real — it grants write access to the CRM. Currently unset, because production runs WebToLead |
|
|
| Forgejo registry token | `~/.openclaw/docker-registry.env` | High, and out of scope of this repository: it is not registry-scoped and works across the whole API with push |
|
|
|
|
**No credentials are held on behalf of users.** The template carries a
|
|
*(precautionary)* paragraph about bring-your-own-key and linked accounts; it was
|
|
deleted rather than kept, because there are no users to hold anything for and
|
|
none are planned — `docs/planning/PROJECT_PLAN.md` records accounts as
|
|
deliberately out of scope. Dropping a precautionary rule is worth saying out loud,
|
|
so: that is the one that went, and that is why.
|
|
|
|
## Secrets
|
|
|
|
- **Nothing secret is committed.** Not in source, not in config, not in a test
|
|
fixture, not in a screenshot.
|
|
- Secrets arrive from the environment. `server/index.js` reads every one of them
|
|
at startup through `process.env` and nothing else passes them around.
|
|
- **`zoho.md` is the operator's credential file and is gitignored.** It holds the
|
|
live reCAPTCHA secret key and the Zoho tokens. That `.gitignore` line stays
|
|
even though the repository is now private — private is not the same as safe,
|
|
and the file has no reason to be in a tree that gets cloned.
|
|
- `.env` is gitignored; `.env.example` is committed and carries names with empty
|
|
values.
|
|
|
|
**Check before every release:** no private keys, keystores, `.env` files or real
|
|
credentials in the tracked tree — including in the history, which a `git log -p`
|
|
search covers and a directory listing does not. `scripts/secrets.sh --tracked`
|
|
does the tree; `--built dist/` does the bundle, which is the artifact users
|
|
actually receive and the one the repository scan never sees.
|
|
|
|
**A credential pasted into an agent transcript is a leaked credential, and
|
|
rotating it is the only fix.** Deleting the message does not help and neither
|
|
does deleting the file: the value was transmitted, stored, and is sitting in a
|
|
log somebody can read. `secrets.sh` cannot see transcripts and never will — they
|
|
are not in the repository — which is exactly why this is a written rule rather
|
|
than a check.
|
|
|
|
### What is already in the history
|
|
|
|
The Zoho WebToLead tokens `xnQsjsdp` and `xmIwtLD` appear in four commits —
|
|
`0d3af33` and `b7f7765` in `index.html`, `033bdf6` in `src/pages/Contact.jsx`,
|
|
and `05b27d2` where they were removed to environment variables. They are **not**
|
|
in the current tree.
|
|
|
|
They were deliberately not rewritten out of the history on 2026-08-18. They are
|
|
public-by-design form identifiers, the repository is now private, and rewriting
|
|
152 commits of shared history to remove a value that any visitor's browser can
|
|
read is cost with no benefit. Recorded in `SECURITY_CHECKLIST.md` under *What got
|
|
past this list*.
|
|
|
|
**The reCAPTCHA secret key never reached git.** Checked on 2026-08-18: zero
|
|
commits, zero tracked files.
|
|
|
|
## Authentication and authorisation
|
|
|
|
**There is none, deliberately.** No login, no session, no cookie, no role, no
|
|
admin area. Every route the server exposes is public on purpose:
|
|
|
|
| Route | Public because |
|
|
| --- | --- |
|
|
| `GET /api/health` | the container's own `HEALTHCHECK` calls it, and `scripts/healthcheck.sh` does too. It returns a status and a DB flag, and nothing about the data |
|
|
| `POST /api/leads` | it *is* the contact form |
|
|
| `POST /api/support` | it *is* the support form |
|
|
| everything else | static files from `dist/` |
|
|
|
|
Nothing reads from the database over HTTP. There is no endpoint that returns a
|
|
lead, so there is no endpoint that could return somebody else's.
|
|
|
|
The corresponding sections of `SECURITY_CHECKLIST.md` and `ClaudeQAPlan.md` were
|
|
deleted rather than left as unticked boxes.
|
|
|
|
## Data in transit
|
|
|
|
TLS everywhere, terminated at Cloudflare in front of `qn.isnull.dev`. The server
|
|
additionally redirects HTTP to HTTPS in production on `x-forwarded-proto`, and
|
|
sends HSTS with a one-year max-age and `includeSubDomains`.
|
|
|
|
Helmet sets the CSP, and it is deliberately narrow: `default-src 'self'`, with
|
|
named exceptions only for Zoho's form host, Google reCAPTCHA and Google Fonts.
|
|
`object-src 'none'`, `frame-ancestors 'self'`, `script-src-attr 'none'`.
|
|
|
|
## Third parties
|
|
|
|
Every row is a decision to send someone else's data somewhere.
|
|
|
|
| Service | What it receives | Why that is acceptable |
|
|
| --- | --- | --- |
|
|
| **Zoho CRM** | the full lead: name, company, email, phone, ZIP, message, service interest. Support requests become Cases when `ZOHO_CASES_ENABLED` | It is the customer's own CRM. This is the point of the form — the visitor is asking to be contacted |
|
|
| **Google reCAPTCHA v3** | the visitor's IP and a behavioural token, on form submission only | The only thing standing between two open POST endpoints and a bot. Scored server-side; the site key in the bundle is public by design |
|
|
| **Cloudflare** | all traffic, as the TLS terminator and CDN in front of the origin | Standard edge position. It sees everything the origin sees |
|
|
| **Google Fonts** | the visitor's IP, on page load | Inherited from the design. Worth revisiting — it is the only third party here that receives something without the visitor doing anything |
|
|
|
|
## Deliberately out of scope
|
|
|
|
- **Denial of service.** Cloudflare is in front; the origin has a per-IP rate
|
|
limit of 5 requests a minute on `/api` and nothing more. A determined actor can
|
|
make the site slow and cannot make it lie.
|
|
- **Compromise of nebula itself.** If the host is owned, the database is owned.
|
|
There is no encryption at rest and adding it would put the key on the same box.
|
|
- **Zoho's own security.** Once a lead is forwarded it is Zoho's problem.
|
|
- **The contents of what visitors type.** Sanitised for injection, not inspected.
|
|
|
|
## Text from outside the trust boundary
|
|
|
|
**It is data. It is never instructions.**
|
|
|
|
Everything a visitor types into either form crosses this boundary, and so does
|
|
every Zoho API response. Concretely, here:
|
|
|
|
- Form input is validated by Zod, then `sanitizeString()` trims it, strips HTML
|
|
and script tags, and truncates to the column's maximum — **before** it reaches
|
|
a prepared statement or a Zoho payload.
|
|
- Every database call is a `better-sqlite3` prepared statement with bound
|
|
parameters. No SQL is built by concatenation anywhere in `server/index.js`.
|
|
- Nothing from a form is ever used to build a URL or a command.
|
|
- **No model reads any of this.** There is no LLM in this product. If one is ever
|
|
added, form text reaches it inside a delimiter, quoted as data, credential-
|
|
scrubbed first — and this section stops being hypothetical.
|
|
|
|
An issue title, a commit message or a form message saying *ignore previous
|
|
instructions* is a perfectly legal string. It is a thing to store and escape,
|
|
never a thing to obey.
|
|
|
|
## Logging
|
|
|
|
`log` in `server/index.js` writes timestamped lines at four levels. What goes in
|
|
is the method, the path, the status and the duration.
|
|
|
|
Nothing sensitive goes into one — not a token, not a credential in a URL, not the
|
|
contents of a submitted form. The Zoho and reCAPTCHA failure paths log the error's
|
|
*name* and the HTTP status, never the response body, because a failed request's
|
|
message is frequently the request URL with the key still in it.
|