CORS_ORIGIN names a host the site is not served from, with a trailing slash #212

Closed
opened 2026-08-18 01:26:21 -05:00 by null · 3 comments
Owner

Found by scripts/check-env.sh on 2026-08-18, running against the deployed container's environment.

What is true now

  • The running container has CORS_ORIGIN=https://queuenorth.com/ — with a trailing slash.
  • docker-compose.yml:23 commits CORS_ORIGIN=https://queuenorth.comwithout one. The running environment has drifted from the repository.
  • Dockerfile:48 has ENV CORS_ORIGIN=* as the image default.
  • The site is served from https://qn.isnull.dev. None of the three names it.

Confirmed against production:

$ curl -X OPTIONS https://qn.isnull.dev/api/leads \
    -H "Origin: https://evil.example" -H "Access-Control-Request-Method: POST"
HTTP/2 204
access-control-allow-origin: https://queuenorth.com/

What it costs — and what it does not. Nothing is broken today, and the issue says so rather than overstating it. src/lib/api.js:1 defaults API_BASE_URL to /api, so the site's own forms are same-origin and never trigger a CORS check. And because the configured value carries a trailing slash it matches no real browser origin, so no cross-origin request is permitted either — it fails closed, which is the safe direction.

So this is wrong and latent, not wrong and live. It becomes live the moment anything is served from a different origin than the API, or the site moves to queuenorth.com and the trailing slash silently blocks every form on it.

What to do. Decide which origin is correct first — that is the part this issue cannot decide:

  • If qn.isnull.dev is the permanent home, set CORS_ORIGIN=https://qn.isnull.dev in docker-compose.yml and in the deployed environment.
  • If queuenorth.com is where this is going, keep the host and drop the trailing slash.
  • Either way, reconcile the running environment with docker-compose.yml, since they currently disagree.

The trap. Dockerfile:48's CORS_ORIGIN=* is the fallback if the compose environment ever stops being applied. A wildcard with credentials: true — which server/index.js:138 sets — is a combination browsers reject outright, so that failure would present as every form breaking at once rather than as a security hole. Worth knowing which symptom to expect.

Why filed and not fixed. Choosing the origin requires knowing whether queuenorth.com is intended to become the public home of this deployment. That is the owner's answer, not a code change.

Verify: curl -X OPTIONS https://qn.isnull.dev/api/leads -H 'Origin: https://evil.example' -H 'Access-Control-Request-Method: POST' -D - returns an access-control-allow-origin naming the origin the site is actually served from, with no trailing slash, and docker-compose.yml matches the deployed environment.

Found by `scripts/check-env.sh` on 2026-08-18, running against the deployed container's environment. **What is true now** - The running container has `CORS_ORIGIN=https://queuenorth.com/` — with a trailing slash. - `docker-compose.yml:23` commits `CORS_ORIGIN=https://queuenorth.com` — **without** one. The running environment has drifted from the repository. - `Dockerfile:48` has `ENV CORS_ORIGIN=*` as the image default. - The site is served from `https://qn.isnull.dev`. None of the three names it. Confirmed against production: ``` $ curl -X OPTIONS https://qn.isnull.dev/api/leads \ -H "Origin: https://evil.example" -H "Access-Control-Request-Method: POST" HTTP/2 204 access-control-allow-origin: https://queuenorth.com/ ``` **What it costs — and what it does not.** Nothing is broken today, and the issue says so rather than overstating it. `src/lib/api.js:1` defaults `API_BASE_URL` to `/api`, so the site's own forms are **same-origin** and never trigger a CORS check. And because the configured value carries a trailing slash it matches no real browser origin, so no cross-origin request is permitted either — it fails closed, which is the safe direction. So this is wrong and latent, not wrong and live. It becomes live the moment anything is served from a different origin than the API, or the site moves to queuenorth.com and the trailing slash silently blocks every form on it. **What to do.** Decide which origin is correct first — that is the part this issue cannot decide: - If `qn.isnull.dev` is the permanent home, set `CORS_ORIGIN=https://qn.isnull.dev` in `docker-compose.yml` and in the deployed environment. - If `queuenorth.com` is where this is going, keep the host and **drop the trailing slash**. - Either way, reconcile the running environment with `docker-compose.yml`, since they currently disagree. **The trap.** `Dockerfile:48`'s `CORS_ORIGIN=*` is the fallback if the compose environment ever stops being applied. A wildcard with `credentials: true` — which `server/index.js:138` sets — is a combination browsers reject outright, so that failure would present as every form breaking at once rather than as a security hole. Worth knowing which symptom to expect. **Why filed and not fixed.** Choosing the origin requires knowing whether queuenorth.com is intended to become the public home of this deployment. That is the owner's answer, not a code change. Verify: `curl -X OPTIONS https://qn.isnull.dev/api/leads -H 'Origin: https://evil.example' -H 'Access-Control-Request-Method: POST' -D -` returns an `access-control-allow-origin` naming the origin the site is actually served from, with no trailing slash, and `docker-compose.yml` matches the deployed environment.
null added this to the Batch 15 — Adoption follow-ups milestone 2026-08-18 01:26:21 -05:00
null added the
P2
label 2026-08-18 01:26:21 -05:00
Author
Owner

Webhook note, recorded here because this issue is the one about configuration drift.

The repository webhook to privacyllc.dev was registered on 2026-08-18 and initially rejected every delivery with 401 bad_signature — it had been created without the shared signing secret. It now carries FORGEJO_WEBHOOK_SECRET. Caught by checking that a delivery actually arrived rather than by assuming registration was enough.

Webhook note, recorded here because this issue is the one about configuration drift. The repository webhook to privacyllc.dev was registered on 2026-08-18 and initially rejected every delivery with `401 bad_signature` — it had been created without the shared signing secret. It now carries `FORGEJO_WEBHOOK_SECRET`. Caught by checking that a delivery actually arrived rather than by assuming registration was enough.
Author
Owner

Decision made by _null on 2026-08-18: queuenorth.com is the permanent public origin. qn.isnull.dev stays as a second ingress to the same container.

That answers the question this issue was blocked on. It also corrected a factual error in the original report and in docs/OPERATIONS.md, both of which said queuenorth.com was not this deployment. It is:

  • Both hostnames serve the identical bundle assets/index-pTFwovIx.js and this server's own /api/health shape.
  • queuenorth.com -> 24.41.108.95, which is this network's own public IP -> nginx-proxy-manager on thor/exodus -> qn-website-dev on nebula.
  • qn.isnull.dev -> Cloudflare -> the same container.
  • www.queuenorth.com 301s to the apex. Both origins carry HSTS and 301 plain HTTP; preflight.sh is clean against each.

Done in the repository (commit to follow this comment):

  • Dockerfile — the fallback was ENV CORS_ORIGIN=*, now https://queuenorth.com. Worth stating why that mattered: the server sets credentials: true, and browsers reject * with credentials outright, so the wildcard fallback would have broken every form rather than over-permitting.
  • docker-compose.yml — already correct, https://queuenorth.com with no slash. No change needed.
  • scripts/healthcheck.sh and scripts/preflight.sh now default to the production origin, with the second front door reachable via HEALTHCHECK_BASE_URL / PREFLIGHT_ORIGIN.
  • check-env.sh's description for this variable said the default was "NOT this deployment". Corrected.
  • Origin references updated across README, OPERATIONS, PROJECT_PLAN, SECURITY, SECURITY_CHECKLIST, architecture and TOOLS.

What is left, and it is one character.

The wrong value is not in this repository. It is in the Portainer stack that actually runs the site:

  • Portainer on nebula, https://192.168.1.11:9443, stack id 58 (qn-website-dev), file at /data/compose/58/docker-compose.yml.
  • Line 21: - CORS_ORIGIN=https://queuenorth.com/ <- the trailing slash.

That file is a separate copy from the one in git and the two have drifted. Editing the repository does not change production.

Why this has not been done. Fixing it means redeploying stack 58, which recreates the container and takes both public front doors down together for a few seconds — a restart of a customer-facing site. Nothing is broken today: the site's own forms are same-origin so no CORS check ever runs, and the trailing slash makes the header match no real origin, which fails closed rather than open. It is worth folding into the next deploy that happens for another reason rather than restarting production for a latent issue.

docs/OPERATIONS.md now documents the whole deploy path, which was an open gap when this issue was filed.

Verify (unchanged): curl -X OPTIONS https://queuenorth.com/api/leads -H 'Origin: https://evil.example' -H 'Access-Control-Request-Method: POST' -D - returns access-control-allow-origin: https://queuenorth.com with no trailing slash, and Portainer stack 58 line 21 matches this repository's docker-compose.yml.

**Decision made by `_null` on 2026-08-18: `queuenorth.com` is the permanent public origin.** `qn.isnull.dev` stays as a second ingress to the same container. That answers the question this issue was blocked on. It also corrected a factual error in the original report and in `docs/OPERATIONS.md`, both of which said queuenorth.com was not this deployment. It is: - Both hostnames serve the identical bundle `assets/index-pTFwovIx.js` and this server's own `/api/health` shape. - `queuenorth.com` -> `24.41.108.95`, which is **this network's own public IP** -> nginx-proxy-manager on thor/exodus -> `qn-website-dev` on nebula. - `qn.isnull.dev` -> Cloudflare -> the same container. - `www.queuenorth.com` 301s to the apex. Both origins carry HSTS and 301 plain HTTP; `preflight.sh` is clean against each. **Done in the repository** (commit to follow this comment): - `Dockerfile` — the fallback was `ENV CORS_ORIGIN=*`, now `https://queuenorth.com`. Worth stating why that mattered: the server sets `credentials: true`, and browsers reject `*` with credentials outright, so the wildcard fallback would have broken every form rather than over-permitting. - `docker-compose.yml` — already correct, `https://queuenorth.com` with no slash. No change needed. - `scripts/healthcheck.sh` and `scripts/preflight.sh` now default to the production origin, with the second front door reachable via `HEALTHCHECK_BASE_URL` / `PREFLIGHT_ORIGIN`. - `check-env.sh`'s description for this variable said the default was "NOT this deployment". Corrected. - Origin references updated across README, OPERATIONS, PROJECT_PLAN, SECURITY, SECURITY_CHECKLIST, architecture and TOOLS. **What is left, and it is one character.** The wrong value is **not in this repository**. It is in the Portainer stack that actually runs the site: - Portainer on nebula, `https://192.168.1.11:9443`, **stack id 58** (`qn-website-dev`), file at `/data/compose/58/docker-compose.yml`. - **Line 21:** `- CORS_ORIGIN=https://queuenorth.com/` <- the trailing slash. That file is a separate copy from the one in git and the two have drifted. Editing the repository does not change production. **Why this has not been done.** Fixing it means redeploying stack 58, which recreates the container and takes both public front doors down together for a few seconds — a restart of a customer-facing site. Nothing is broken today: the site's own forms are same-origin so no CORS check ever runs, and the trailing slash makes the header match no real origin, which fails closed rather than open. It is worth folding into the next deploy that happens for another reason rather than restarting production for a latent issue. `docs/OPERATIONS.md` now documents the whole deploy path, which was an open gap when this issue was filed. Verify (unchanged): `curl -X OPTIONS https://queuenorth.com/api/leads -H 'Origin: https://evil.example' -H 'Access-Control-Request-Method: POST' -D -` returns `access-control-allow-origin: https://queuenorth.com` with no trailing slash, and Portainer stack 58 line 21 matches this repository's `docker-compose.yml`.
Author
Owner

Fixed and DEPLOYED in v0.9.4 on 2026-08-18.

The wrong value was never in this repository — docker-compose.yml has always been correct. It was line 21 of Portainer stack 58's own compose file, a separate copy that had drifted. Corrected by bash scripts/deploy.sh --tag v0.9.4 --fix-cors, which rewrote that line in the same PUT that moved the image, so production restarted once rather than twice.

Decision that unblocked it: _null confirmed queuenorth.com as the permanent public origin.

Live evidence, after the deploy:

$ curl -X OPTIONS https://queuenorth.com/api/leads \
    -H 'Origin: https://evil.example' -H 'Access-Control-Request-Method: POST' -D -
access-control-allow-origin: https://queuenorth.com

No trailing slash, and it names the origin the site is actually served from. The stack file and this repository's docker-compose.yml now agree on CORS_ORIGIN.

Also verified in the same deploy: both front doors healthy, the live bundle still carries the reCAPTCHA site key, and the leads table is unchanged at 3 rows.

Verify: the curl above returns access-control-allow-origin: https://queuenorth.com with no trailing slash, and line 21 of Portainer stack 58 matches docker-compose.yml.

Fixed and DEPLOYED in v0.9.4 on 2026-08-18. The wrong value was never in this repository — docker-compose.yml has always been correct. It was line 21 of Portainer stack 58's own compose file, a separate copy that had drifted. Corrected by `bash scripts/deploy.sh --tag v0.9.4 --fix-cors`, which rewrote that line in the same PUT that moved the image, so production restarted once rather than twice. Decision that unblocked it: _null confirmed queuenorth.com as the permanent public origin. Live evidence, after the deploy: $ curl -X OPTIONS https://queuenorth.com/api/leads \ -H 'Origin: https://evil.example' -H 'Access-Control-Request-Method: POST' -D - access-control-allow-origin: https://queuenorth.com No trailing slash, and it names the origin the site is actually served from. The stack file and this repository's docker-compose.yml now agree on CORS_ORIGIN. Also verified in the same deploy: both front doors healthy, the live bundle still carries the reCAPTCHA site key, and the leads table is unchanged at 3 rows. Verify: the curl above returns `access-control-allow-origin: https://queuenorth.com` with no trailing slash, and line 21 of Portainer stack 58 matches docker-compose.yml.
null closed this issue 2026-08-18 03:38:04 -05:00
Sign in to join this conversation.
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: null/Queue-North-Website#212
No description provided.