refactor: newsletter signup posts no credential (SEC-14-R1 option a)
CI / Run tests (pull_request) Successful in 15s
CI / Secret scan (gitleaks) (pull_request) Failing after 12s

Rework PR #15 per security review SEC-14-R1: the client no longer carries
an API token. js/newsletter-config.js ships only the non-secret endpoint,
enforced https-only at config load time via validateEndpoint() (mirroring
the protocol allowlist in js/reading-list.js); js/newsletter.js POSTs
email-only with no Authorization header. Failure paths keep the single
user-safe message that never leaks token, endpoint, status, or raw body;
success still shows the confirmation. CI gains a gitleaks step that fails
on any secret hit; README documents the server-side token, the residual
signup-abuse risk, and the authoritative server-side validation follow-up.
This commit is contained in:
implementer
2026-08-26 11:27:11 +00:00
parent cb9cf703a8
commit 86aa3afbbf
5 changed files with 274 additions and 152 deletions
+37 -10
View File
@@ -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);
+16 -20
View File
@@ -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;
}