src/features/middleware.ts
Rama main · réplica de lectura del repositorio original.
/**
* Middleware functions for request processing
*/
import { settings } from "#db/settings.ts";
import { compact } from "#fp";
import { BUNNY_FONTS_ORIGIN } from "#routes/public/bunny-fonts.ts";
import { encodeBody } from "#routes/response.ts";
import { ASSET_CDN_ORIGIN } from "#shared/asset-paths.ts";
import {
getEmbedHosts,
isBotpoisonEnabled,
isSecureMode,
} from "#shared/config.ts";
import { buildFrameAncestors } from "#shared/embed-hosts.ts";
import { paymentProviderUsesSandbox } from "#shared/payment-provider-status.ts";
import { providerCheckoutFormOrigins } from "#shared/payment-providers.ts";
import type { PaymentProviderType } from "#types";
/**
* Security headers for all responses
*/
const BASE_SECURITY_HEADERS: Record<string, string> = {
"referrer-policy": "strict-origin-when-cross-origin",
"x-content-type-options": "nosniff",
"x-robots-tag": "noindex, nofollow",
};
/** Payment config for CSP header construction */
export type PaymentCspConfig = {
provider: PaymentProviderType | null;
sandbox?: boolean | undefined;
};
/**
* An embeddable page omits frame-ancestors here. `applySecurityHeaders` adds it
* when embed host restrictions are configured.
*
* Every provider redirects server-side and embeds no SDK, so form-action is the
* only directive that names provider hosts.
*/
export const buildCspHeader = (
embeddable: boolean,
payment?: PaymentCspConfig,
botpoisonEnabled = false,
assetCdnOrigin: string | null = ASSET_CDN_ORIGIN,
bunnyFonts = false,
): string => {
const directives = [
"default-src 'self'",
"img-src 'self' https://tile.openstreetmap.org",
"base-uri 'self'",
"object-src 'none'",
];
if (assetCdnOrigin) {
directives.push(`script-src 'self' ${assetCdnOrigin}`);
}
if (assetCdnOrigin || bunnyFonts) {
const styleSources = compact([
"'self'",
assetCdnOrigin,
...(bunnyFonts ? [BUNNY_FONTS_ORIGIN] : []),
]);
directives.push(`style-src ${styleSources.join(" ")}`);
}
if (bunnyFonts) {
directives.push(`font-src 'self' ${BUNNY_FONTS_ORIGIN}`);
}
if (botpoisonEnabled) {
directives.push("connect-src 'self' https://api.botpoison.com");
}
// The buyer's form posts straight to the provider's hosted checkout, so the
// policy must name that provider's origins or the browser blocks the
// redirect. The registry declares them, so a new provider cannot reach here
// and silently get a policy that blocks its own checkout.
const provider = payment?.provider ?? null;
const checkoutOrigins =
provider === null
? []
: providerCheckoutFormOrigins(provider, payment?.sandbox === true);
directives.push(["form-action 'self'", ...checkoutOrigins].join(" "));
if (!embeddable) {
directives.unshift("frame-ancestors 'none'");
}
return directives.join("; ");
};
/**
* Get security headers for a response
*/
export const getSecurityHeaders = (
path: string,
csp = buildCspHeader(EMBEDDABLE_PATH.test(path)),
): Record<string, string> => {
const embeddable = EMBEDDABLE_PATH.test(path);
// The indexability read sits behind the path check. A path the public site
// never serves (a static asset, an admin page) skips it. The custom-CSS
// read in applySecurityHeaders has no such gate: every page the layout
// renders links /custom.css, so every routed response needs the same rule.
const indexable =
embeddable || (PUBLIC_SITE_PATH.test(path) && settings.features.site);
return {
...BASE_SECURITY_HEADERS,
...(!embeddable && { "x-frame-options": "DENY" }),
...(indexable && { "x-robots-tag": "index, follow" }),
...(isSecureMode() && {
"strict-transport-security":
"max-age=63072000; includeSubDomains; preload",
}),
"content-security-policy": csp,
};
};
/** One slug: letter/number runs joined by single hyphens or underscores. */
const SLUG = "[a-z0-9]+(?:[-_][a-z0-9]+)*";
/** Matches /ticket/ with one or more slugs separated by +. Only a public
* ticket page is embeddable. Paths are normalized to strip trailing
* slashes. */
export const EMBEDDABLE_PATH = new RegExp(`^/ticket/${SLUG}(?:\\+${SLUG})*$`);
/** The paths the public site serves when its feature is on. They are the
* home page, the listings page, the terms, the news pages, the custom
* pages, the contact page, and the order page. The x-robots-tag follows
* this list. A new public page joins this list to be indexable. Only one
* visitor-facing page the site feature serves matches. The paths are
* normalized to strip trailing slashes. */
export const PUBLIC_SITE_PATH = new RegExp(
`^(?:/|/listings|/terms|/contact|/order|/news(?:/${SLUG})?|/page/${SLUG})$`,
);
/**
* Check if path is a webhook endpoint that accepts JSON
*/
export const isWebhookPath = (path: string): boolean =>
path === "/payment/webhook" || path === "/sms/webhook";
/** Pattern matching scan API paths (the scanner posts JSON check-ins and
* reads its offline ticket cache as JSON) */
const SCAN_API_PATTERN = /^\/admin\/(listing|groups)\/\d+\/scan(?:-cache)?$/;
/** Pattern for public API paths */
const API_PATH_PATTERN = /^\/api\//;
/** Pattern for Apple Wallet web service paths (PassKit protocol) */
const WALLET_WEBSERVICE_PATTERN = /^\/v1\//;
/**
* Check if path is a JSON API endpoint. The patterns live here so this
* always-loaded middleware never drags route modules into boot for a regex.
*/
export const isJsonApiPath = (path: string): boolean =>
SCAN_API_PATTERN.test(path) ||
API_PATH_PATTERN.test(path) ||
WALLET_WEBSERVICE_PATTERN.test(path);
/**
* Validate Content-Type for POST requests. A non-POST request is valid. A
* POST request is valid when its Content-Type is correct. Webhook endpoints
* accept application/json. All other endpoints require form-urlencoded.
*/
/** The request's Content-Type header, lowercased — HTTP header values are
* case-insensitive, so every caller matches casings uniformly. */
export const lowerContentType = (request: Request): string =>
(request.headers.get("content-type") ?? "").toLowerCase();
export const isValidContentType = (request: Request, path: string): boolean => {
if (request.method !== "POST") {
return true;
}
// The inter-instance credentials endpoint carries no body and authenticates
// via a bearer key, so there is nothing to CSRF-protect.
if (path === "/instance/site-credentials") {
return true;
}
const contentType = lowerContentType(request);
// Webhook and JSON API endpoints accept JSON
if (isWebhookPath(path) || isJsonApiPath(path)) {
return contentType.startsWith("application/json");
}
// All other POST endpoints require form-urlencoded or multipart (for file uploads)
return (
contentType.startsWith("application/x-www-form-urlencoded") ||
contentType.startsWith("multipart/form-data")
);
};
/**
* Create Content-Type rejection response
*/
export const contentTypeRejectionResponse = (): Response =>
new Response(encodeBody("Bad Request: Invalid Content-Type"), {
headers: {
"content-type": "text/plain",
// The rejection names no page, so its robots tag never depends on the
// public site feature.
...getSecurityHeaders("/bad-content-type"),
},
status: 400,
});
/** Tracking parameters added by social media and ad platforms */
const TRACKING_PARAMS = [
"fbclid",
"gclid",
"gad_source",
"utm_source",
"utm_medium",
"utm_campaign",
"utm_term",
"utm_content",
"utm_id",
];
/** Check if a query parameter key is a tracking parameter */
const isTrackingParam = (key: string): boolean => TRACKING_PARAMS.includes(key);
/**
* Get clean URL path with tracking parameters stripped.
* Returns the clean path (preserving non-tracking query params) or null if no stripping needed.
*/
export const getCleanUrl = (url: URL): string | null => {
let hasTracking = false;
for (const key of url.searchParams.keys()) {
if (isTrackingParam(key)) {
hasTracking = true;
break;
}
}
if (!hasTracking) return null;
const clean = new URLSearchParams();
for (const [key, value] of url.searchParams.entries()) {
if (!isTrackingParam(key)) {
clean.append(key, value);
}
}
const search = clean.toString();
return search ? `${url.pathname}?${search}` : url.pathname;
};
/**
* Apply security headers to a response.
* Mutates headers in-place to avoid re-reading the response body, which
* intermittently fails with "error decoding response body" on Bunny Edge.
* For embeddable pages, fetches embed host restrictions and adds frame-ancestors.
* Adds Cache-Control: private, no-store to dynamic responses (those without
* an explicit cache-control header) to prevent CDN caching issues.
*/
export const applySecurityHeaders = async (
response: Response,
path: string,
): Promise<Response> => {
const embeddable = EMBEDDABLE_PATH.test(path);
const provider = settings.paymentProvider;
const sandbox = provider !== null && paymentProviderUsesSandbox(provider);
// Every page the layout renders links /custom.css, so the policy must
// follow the stylesheet's saved flag on every routed response. The flag is
// computed when the CSS was saved. The read is the cached snapshot, never a
// query of its own.
const baseCsp = buildCspHeader(
embeddable,
{ provider, sandbox },
isBotpoisonEnabled(),
ASSET_CDN_ORIGIN,
settings.customCssBunnyFonts,
);
const frameAncestors = embeddable
? buildFrameAncestors(await getEmbedHosts())
: null;
const csp = frameAncestors ? `${frameAncestors}; ${baseCsp}` : baseCsp;
const securityHeaders = getSecurityHeaders(path, csp);
// Check before setting security headers (they do not include cache-control)
const hasCacheControl = response.headers.has("cache-control");
for (const [key, value] of Object.entries(securityHeaders)) {
response.headers.set(key, value);
}
// A public page that did not answer 2xx is not an indexable page. An
// unknown slug, an unconfigured page, or a redirect must not claim
// index, follow.
if (response.status < 200 || response.status >= 300) {
response.headers.set("x-robots-tag", "noindex, nofollow");
}
// Override x-robots-tag for hidden listings (signal header set by route handlers)
if (response.headers.has("x-robots-noindex")) {
response.headers.set("x-robots-tag", "noindex, nofollow");
response.headers.delete("x-robots-noindex");
}
// Prevent CDN from caching dynamic responses — static assets already set
// their own cache-control (for example, "public, max-age=31536000, immutable")
if (!hasCacheControl) {
response.headers.set("cache-control", "private, no-store");
}
return response;
};