Ctrl K
ComunidadGuías…

src/features/middleware.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * 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;
};