diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 2815f6a..666af69 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -13,3 +13,18 @@ jobs: - uses: actions/checkout@v4 - name: Run test suite run: npm test + + gitleaks: + name: Secret scan (gitleaks) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install gitleaks + run: | + curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.18.4/gitleaks_8.18.4_linux_x64.tar.gz -o gitleaks.tar.gz + tar -xzf gitleaks.tar.gz gitleaks + # `gitleaks detect` exits non-zero on any finding, so this step fails the + # build on any secret hit. `--no-git` scans the checked-out tree only; + # `--redact` masks matched secrets in the log output. + - name: Run gitleaks (fail on any hit) + run: ./gitleaks detect --source . --no-git --redact -v diff --git a/README.md b/README.md index 0a1e729..dcbaccc 100644 --- a/README.md +++ b/README.md @@ -17,19 +17,34 @@ vanilla JavaScript. ## Newsletter signup The signup form on `newsletter.html` POSTs the visitor's email to a serverless -endpoint. Everything is configured in one file — `js/newsletter-config.js`: +endpoint. The client ships only the endpoint URL — no credential is ever sent +in the request: -- `NEWSLETTER_ENDPOINT` — the serverless endpoint the form POSTs to. -- `NEWSLETTER_API_TOKEN` — the API token sent in the `Authorization` header. +- `NEWSLETTER_ENDPOINT` — the serverless endpoint the form POSTs to. This is + the **only** thing configured in `js/newsletter-config.js`, and it is + enforced `https://`-only at config load time and by tests (mirroring the + protocol allowlist in `js/reading-list.js`). -**Security:** a real API token must never be committed to the repository. The -committed default is an empty placeholder; the deployer sets the real token in -`js/newsletter-config.js` at deploy time, and only then. The page logic reads -the token from this config module and never hardcodes one. +**Authentication is server-side only.** The serverless function reads its API +token from platform env/secrets at deploy time — never from this repository and +never from any client-served asset. Do not add a token to `js/newsletter-config.js` +or anywhere else in the tree; anything committed here is public. -When the token is missing or the endpoint rejects it, the visitor sees a fixed, -user-safe error message — the token or endpoint internals are never surfaced. -After a successful signup a confirmation message is shown. +When the endpoint is unavailable or rejects the submission, the visitor sees a +fixed, user-safe error message — the server-held token, the endpoint, the status +code, and any raw response body are never surfaced. After a successful signup a +confirmation message is shown. + +**Server-side validation (required follow-up on the function, not this diff):** +the serverless function must authoritatively validate submissions before +processing — email syntax, length caps, pinned `Content-Type`, and rejection of +unknown fields. Client-side checks here are UX only and are bypassable by +direct API calls. + +**Residual signup-abuse risk (accepted, out of scope):** this pass adds no spam +filtering or captcha. Scripted signups remain possible, so the serverless +function must mitigate abuse server-side with rate limiting and an origin +allowlist. ## Reading list @@ -79,11 +94,11 @@ contact.html Contact page newsletter.html Newsletter signup page css/style.css Global + responsive styles data/reading-list.js Curated reading list data (edit to add links) -js/newsletter-config.js Newsletter endpoint + API token (edit at deploy time) -js/newsletter.js Newsletter signup wiring: POST + user-safe errors (tested) +js/newsletter-config.js Newsletter endpoint (non-secret, https-only) — edit at deploy time +js/newsletter.js Newsletter signup wiring: credential-free POST + user-safe errors (tested) js/mailto.js Pure mailto: URL builder (unit tested) js/contact.js Contact form wiring (browser + tests) js/reading-list.js Reading list renderer: data file -> grouped HTML (tested) tests/ Node built-in test suite -.gitea/workflows/ ci.yml runs `npm test` on PRs and pushes to main +.gitea/workflows/ ci.yml runs `npm test` plus a gitleaks secret scan (fails on any hit) on PRs and pushes to main ``` diff --git a/js/newsletter-config.js b/js/newsletter-config.js index ac8f9f4..66f77f2 100644 --- a/js/newsletter-config.js +++ b/js/newsletter-config.js @@ -1,17 +1,44 @@ /** * Newsletter signup configuration. * - * The blog is a static site with no build step, so the serverless endpoint - * and its API token are configured here — edit this file when deploying. - * - * Security note: a real API token must NEVER be committed to the repository. - * The committed default below is intentionally empty; the deployer fills in - * the token at deploy time (and only then). The page logic reads the token - * from this module and never hardcodes one. + * Only the non-secret serverless endpoint URL lives here. The serverless + * function authenticates with an API token it reads from platform env/secrets + * at deploy time — never from this module and never from any client-served + * asset. Do NOT add a token or any other secret to this file: the client must + * never carry a credential, and anything committed here is public. */ -/** The serverless endpoint the signup form POSTs to. */ +/** True when the value is an absolute URL whose protocol is https:. */ +export function isHttpsUrl(value) { + try { + return new URL(String(value)).protocol === "https:"; + } catch { + return false; + } +} + +/** + * Enforce the https-only rule on a configured endpoint, mirroring the protocol + * allowlist pattern in js/reading-list.js (tightened to https). Throws when the + * endpoint would silently downgrade submissions to plaintext. + * + * @param {string} endpoint + * @returns {true} + */ +export function validateEndpoint(endpoint) { + if (!isHttpsUrl(endpoint)) { + throw new Error("NEWSLETTER_ENDPOINT must be an https:// URL"); + } + return true; +} + +/** + * The serverless endpoint the signup form POSTs to. + * + * https-only is asserted here at config load time (and covered by tests), so a + * deployer pointing this at an http:// URL fails fast instead of shipping a + * downgraded endpoint. + */ export const NEWSLETTER_ENDPOINT = "https://example.com/api/newsletter-subscribers"; -/** API token for the endpoint. Empty by default — set at deploy time. */ -export const NEWSLETTER_API_TOKEN = ""; +validateEndpoint(NEWSLETTER_ENDPOINT); diff --git a/js/newsletter.js b/js/newsletter.js index fbe176e..6a0b5cd 100644 --- a/js/newsletter.js +++ b/js/newsletter.js @@ -1,14 +1,12 @@ /** * Newsletter signup — posts the visitor's email to a configured serverless - * endpoint using the API token from `newsletter-config.js`. + * endpoint. No credential is ever sent: the serverless function authenticates + * with an API token it reads from platform env/secrets at deploy time. * * Pure-ish by design (no DOM, injectable fetch), so every behaviour is unit * testable in Node; the browser wiring at the bottom is guarded accordingly. */ -import { - NEWSLETTER_API_TOKEN, - NEWSLETTER_ENDPOINT, -} from "./newsletter-config.js"; +import { NEWSLETTER_ENDPOINT } from "./newsletter-config.js"; /** User-safe message shown when the signup cannot be completed. */ export const NEWSLETTER_ERROR_MESSAGE = @@ -37,23 +35,23 @@ export function readFormEmail(form) { } /** - * POST the email to the serverless endpoint with the API token. - * - * The token travels in the `Authorization` header; it is never put in the - * body, the URL, or any message. A missing token fails fast with a user-safe - * error and no network call. Any non-2xx response (invalid token, endpoint - * error) and any network failure map to the same generic message, so the - * secret and endpoint internals are never surfaced to the visitor. + * POST the email to the serverless endpoint with no credential in the request + * — no auth header, no token in the body or URL. The serverless function reads + * its API token from platform env/secrets, never from the client. A clearly + * invalid email fails fast with a user-safe error and no network call. Any + * non-2xx response and any network failure map to the same generic message, so + * the endpoint internals, any status code, and any raw response body are never + * surfaced to the visitor. * * @param {string} email - * @param {{endpoint?: string, token?: string, fetchImpl?: typeof fetch}} [options] + * @param {{endpoint?: string, fetchImpl?: typeof fetch}} [options] * @returns {Promise<{ok: boolean, message: string}>} */ export async function submitNewsletterSignup( email, - { endpoint = NEWSLETTER_ENDPOINT, token = NEWSLETTER_API_TOKEN, fetchImpl = fetch } = {} + { endpoint = NEWSLETTER_ENDPOINT, fetchImpl = fetch } = {}, ) { - if (!token) { + if (!isValidEmail(email)) { return { ok: false, message: NEWSLETTER_ERROR_MESSAGE }; } @@ -63,7 +61,6 @@ export async function submitNewsletterSignup( method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, }, body: JSON.stringify({ email }), }); @@ -82,17 +79,16 @@ export async function submitNewsletterSignup( * the result (confirmation or user-safe error) through `setStatus`. * * @param {HTMLFormElement} form - * @param {{endpoint?: string, token?: string, fetchImpl?: typeof fetch, setStatus?: (message: string, kind: "success"|"error") => void}} [options] + * @param {{endpoint?: string, fetchImpl?: typeof fetch, setStatus?: (message: string, kind: "success"|"error") => void}} [options] * @returns {Promise<{ok: boolean, message: string}>} */ export async function handleNewsletterSubmit( form, { endpoint = NEWSLETTER_ENDPOINT, - token = NEWSLETTER_API_TOKEN, fetchImpl = fetch, setStatus = defaultSetStatus, - } = {} + } = {}, ) { const email = readFormEmail(form); if (!isValidEmail(email)) { @@ -100,7 +96,7 @@ export async function handleNewsletterSubmit( return { ok: false, message: NEWSLETTER_ERROR_MESSAGE }; } - const result = await submitNewsletterSignup(email, { endpoint, token, fetchImpl }); + const result = await submitNewsletterSignup(email, { endpoint, fetchImpl }); setStatus(result.message, result.ok ? "success" : "error"); return result; } diff --git a/tests/newsletter.test.js b/tests/newsletter.test.js index 4189769..615edbe 100644 --- a/tests/newsletter.test.js +++ b/tests/newsletter.test.js @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { readdirSync, readFileSync, statSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { @@ -12,8 +12,9 @@ import { submitNewsletterSignup, } from "../js/newsletter.js"; import { - NEWSLETTER_API_TOKEN, NEWSLETTER_ENDPOINT, + isHttpsUrl, + validateEndpoint, } from "../js/newsletter-config.js"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); @@ -34,7 +35,7 @@ function fakeForm(email) { }; } -/** Record calls and respond — pass an object or a (url, init) => response fn. */ +/** Record fetch calls; respond with an object or via a (url, init) => response fn. */ function fakeFetch(respond) { const calls = []; const impl = async (url, init) => { @@ -53,20 +54,20 @@ test("newsletter page renders an email field and a submit button", () => { assert.match(newsletterHtml, /