UNPKG

@appwarden/middleware

Version:

Instantly disable all user interaction with your app deployed on Cloudflare or Vercel

557 lines (545 loc) 17.6 kB
import { APPWARDEN_HEARTBEAT_ROUTE, AppwardenConfigErrorMessages, HEARTBEAT_CONFIG_ERRORS_MAX_SERIALIZED_BYTES, HEARTBEAT_CONFIG_ERROR_MAX_CODE_LENGTH, HEARTBEAT_CONFIG_ERROR_MAX_COUNT, HEARTBEAT_CONFIG_ERROR_MAX_MESSAGE_LENGTH, HEARTBEAT_CONFIG_ERROR_MAX_PATH_DEPTH, HEARTBEAT_CONFIG_ERROR_MAX_PATH_SEGMENT_LENGTH, HEARTBEAT_CONTRACT_VERSION, HEARTBEAT_RESPONSE_BODY_MAX_SERIALIZED_BYTES, LOCKDOWN_TEST_EXPIRY_MS, MIDDLEWARE_VERSION, validateHeartbeatResponseBody } from "./chunk-3OXAKJPA.js"; // src/utils/build-lock-page-url.ts function isValidLockPageSlug(lockPageSlug) { return !lockPageSlug.includes("://") && !lockPageSlug.startsWith("//") && !lockPageSlug.includes("\\"); } function normalizeLockPageSlug(lockPageSlug) { if (!isValidLockPageSlug(lockPageSlug)) { throw new Error( `Invalid lockPageSlug: "${lockPageSlug}". Absolute or protocol-relative URLs are not allowed.` ); } return lockPageSlug.startsWith("/") ? lockPageSlug : `/${lockPageSlug}`; } function buildLockPageUrl(lockPageSlug, requestUrl) { const normalizedSlug = normalizeLockPageSlug(lockPageSlug); return new URL(normalizedSlug, requestUrl); } function normalizeTrailingSlash(path) { if (path === "/") return path; return path.endsWith("/") ? path.slice(0, -1) : path; } function isOnLockPage(lockPageSlug, requestUrl) { const normalizedSlug = normalizeTrailingSlash( normalizeLockPageSlug(lockPageSlug) ); const url = typeof requestUrl === "string" ? new URL(requestUrl) : requestUrl; const normalizedPathname = normalizeTrailingSlash(url.pathname); return normalizedPathname === normalizedSlug; } // src/utils/create-redirect.ts var TEMPORARY_REDIRECT_STATUS = 302; var createRedirect = (url) => { return new Response(null, { status: TEMPORARY_REDIRECT_STATUS, headers: { Location: url.toString() } }); }; // src/utils/print-message.ts var addSlashes = (str) => str.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$/g, "\\$").replace(/"/g, '\\"').replace(/'/g, "\\'").replace(/\u0000/g, "\\0").replace(/<\/script>/gi, "<\\/script>"); var printMessage = (message) => `[@appwarden/middleware] ${addSlashes(message)}`; // src/utils/debug.ts var debug = (isDebug) => (...msg) => { if (!isDebug) return; const parts = msg.map((m) => { let content; if (m instanceof Error) { content = m.stack ?? m.message; } else if (typeof m === "object" && m !== null) { try { content = JSON.stringify(m); } catch { try { content = String(m); } catch { content = "[Unserializable value]"; } } } else { content = String(m); } return content; }); const message = parts.join(" "); console.log(printMessage(message)); }; // src/utils/memory-cache.ts var MemoryCache = class { cache = /* @__PURE__ */ new Map(); maxSize; constructor(options) { this.maxSize = options.maxSize; } get(key) { let item; if (this.cache.has(key)) { item = this.cache.get(key); this.cache.delete(key); if (item !== void 0) { this.cache.set(key, item); } } return item; } put(key, value) { if (this.cache.has(key)) { this.cache.delete(key); } else if (this.cache.size >= this.maxSize) { const firstKey = this.cache.keys().next().value; if (firstKey !== void 0) { this.cache.delete(firstKey); } } this.cache.set(key, value); } getValues() { return this.cache; } // the default value will be expired here static isExpired = (lockValue) => { if (!lockValue) { return true; } return Date.now() > lockValue.lastCheck + 3e4; }; static isTestExpired = (lockValue) => { if (!lockValue) { return true; } return Date.now() > lockValue.isLockedTest + LOCKDOWN_TEST_EXPIRY_MS; }; }; // src/utils/heartbeat.ts var DEFAULT_HEARTBEAT_CONFIG_ERROR_CODE = "custom"; var DEFAULT_HEARTBEAT_CONFIG_ERROR_MESSAGE = "Appwarden configuration validation failed"; var HEARTBEAT_CONSTRUCTION_FAILURE_BODY = JSON.stringify({ error: "appwarden_heartbeat_construction_failed" }); function getExpectedType(issue) { if ("expected" in issue && typeof issue.expected === "string") { return issue.expected; } if (issue.code === "invalid_union" && "unionErrors" in issue && Array.isArray(issue.unionErrors) && issue.unionErrors.length > 0) { const literalValues = []; let allAreLiterals = true; for (const unionError of issue.unionErrors) { if (unionError && "issues" in unionError && Array.isArray(unionError.issues) && unionError.issues.length > 0) { const firstIssue = unionError.issues[0]; if (firstIssue.code === "invalid_literal" && "expected" in firstIssue) { literalValues.push(String(firstIssue.expected)); } else { allAreLiterals = false; break; } } } if (allAreLiterals && literalValues.length > 0) { return literalValues.join(" | "); } const expectedTypes = /* @__PURE__ */ new Set(); for (const unionError of issue.unionErrors) { if (unionError && "issues" in unionError && Array.isArray(unionError.issues) && unionError.issues.length > 0) { const firstIssue = unionError.issues[0]; if (typeof firstIssue.expected === "string") { expectedTypes.add(firstIssue.expected); } } } if (expectedTypes.size > 0) { return Array.from(expectedTypes).join(" | "); } } return void 0; } function getIssueParams(issue) { if (issue.code !== "custom") { return void 0; } return issue.params; } function getAppwardenControlledMessage(issue, path) { const key = getIssueParams(issue)?.appwardenErrorKey; if (typeof key === "string" && Object.prototype.hasOwnProperty.call(AppwardenConfigErrorMessages, key)) { return AppwardenConfigErrorMessages[key]; } const lastSegment = path.length > 0 ? path[path.length - 1] : void 0; if (lastSegment === "appwardenApiToken" && issue.code === "invalid_type" && (issue.received === "undefined" || issue.received === "null")) { return AppwardenConfigErrorMessages["APPWARDEN_API_TOKEN_MISSING" /* AppwardenApiTokenMissing */]; } return void 0; } function createSanitizedMessage(issue, path) { const controlledMessage = getAppwardenControlledMessage(issue, path); if (controlledMessage) { return controlledMessage; } const fieldName = path.length > 0 ? path[path.length - 1] : "field"; const code = issue.code; switch (code) { case "invalid_type": { const expectedType = getExpectedType(issue); return expectedType ? `Invalid type for ${fieldName}. Expected ${expectedType}` : `Invalid type for ${fieldName}`; } case "invalid_literal": return `Invalid value for ${fieldName}`; case "unrecognized_keys": return `Unrecognized keys in ${fieldName}`; case "invalid_union": { const expectedType = getExpectedType(issue); return expectedType ? `Invalid type for ${fieldName}. Expected ${expectedType}` : `Invalid union value for ${fieldName}`; } case "invalid_enum_value": return `Invalid enum value for ${fieldName}`; case "invalid_arguments": return `Invalid arguments for ${fieldName}`; case "invalid_return_type": { const expectedType = getExpectedType(issue); return expectedType ? `Invalid return type for ${fieldName}. Expected ${expectedType}` : `Invalid return type for ${fieldName}`; } case "invalid_date": return `Invalid date for ${fieldName}`; case "invalid_string": return `Invalid string format for ${fieldName}`; case "too_small": return `Value too small for ${fieldName}`; case "too_big": return `Value too large for ${fieldName}`; case "invalid_intersection_types": return `Invalid intersection types for ${fieldName}`; case "not_multiple_of": return `Value not a multiple of required value for ${fieldName}`; case "not_finite": return `Value must be finite for ${fieldName}`; case "custom": return `Validation failed for ${fieldName}`; default: return `Validation error for ${fieldName}`; } } function truncateWithEllipsis(value, maxLength) { if (value.length <= maxLength) { return value; } if (maxLength <= 3) { return value.substring(0, maxLength); } return value.substring(0, maxLength - 3) + "..."; } function sanitizePathSegment(segment) { if (typeof segment === "number" && Number.isFinite(segment)) { return Number.isSafeInteger(segment) ? Math.max(0, segment) : Math.max(0, Math.trunc(segment)); } return truncateWithEllipsis( typeof segment === "string" ? segment : String(segment), HEARTBEAT_CONFIG_ERROR_MAX_PATH_SEGMENT_LENGTH ); } function sanitizePath(path) { const truncatedPath = path.slice(0, HEARTBEAT_CONFIG_ERROR_MAX_PATH_DEPTH); return truncatedPath.map(sanitizePathSegment); } function truncateMessage(message) { return truncateWithEllipsis( message, HEARTBEAT_CONFIG_ERROR_MAX_MESSAGE_LENGTH ); } function truncateCode(code) { return truncateWithEllipsis(code, HEARTBEAT_CONFIG_ERROR_MAX_CODE_LENGTH); } function normalizeNonEmptyString(value, fallback, truncate) { const normalizedValue = truncate(value.trim()); if (normalizedValue.length > 0) { return normalizedValue; } return truncate(fallback); } function getSerializedJsonByteLength(value) { return new TextEncoder().encode(JSON.stringify(value)).length; } function isConfigErrorsWithinByteBudget(configErrors) { return getSerializedJsonByteLength(configErrors) <= HEARTBEAT_CONFIG_ERRORS_MAX_SERIALIZED_BYTES; } function isResponseBodyWithinByteBudget(body) { return getSerializedJsonByteLength(body) <= HEARTBEAT_RESPONSE_BODY_MAX_SERIALIZED_BYTES; } function createHeartbeatConfigError(path, code, message) { return { path: sanitizePath(path), code: normalizeNonEmptyString( code, DEFAULT_HEARTBEAT_CONFIG_ERROR_CODE, truncateCode ), message: normalizeNonEmptyString( message, DEFAULT_HEARTBEAT_CONFIG_ERROR_MESSAGE, truncateMessage ) }; } function normalizeHeartbeatConfigErrors(configErrors) { const normalizedConfigErrors = configErrors.slice(0, HEARTBEAT_CONFIG_ERROR_MAX_COUNT).map( (configError) => createHeartbeatConfigError( configError.path, configError.code, configError.message ) ); while (normalizedConfigErrors.length > 0 && !isConfigErrorsWithinByteBudget(normalizedConfigErrors)) { normalizedConfigErrors.pop(); } return normalizedConfigErrors; } function sanitizeConfigErrors(error) { if (!error) { return []; } const errors = []; const issues = error.issues.slice(0, HEARTBEAT_CONFIG_ERROR_MAX_COUNT); for (const issue of issues) { const sanitizedPath = sanitizePath(issue.path); const message = truncateMessage( createSanitizedMessage(issue, sanitizedPath) ); errors.push({ path: sanitizedPath, code: issue.code, message }); } return errors; } function createHeartbeatResponseBody(service, configErrors = []) { const normalizedConfigErrors = normalizeHeartbeatConfigErrors(configErrors); const body = { app: "appwarden", kind: "heartbeat", status: "ok", contractVersion: HEARTBEAT_CONTRACT_VERSION, service, version: MIDDLEWARE_VERSION, configErrors: normalizedConfigErrors }; while (body.configErrors.length > 0 && !isResponseBodyWithinByteBudget(body)) { body.configErrors.pop(); } return validateHeartbeatResponseBody(body); } function createHeartbeatConstructionFailureResponse() { return new Response(HEARTBEAT_CONSTRUCTION_FAILURE_BODY, { status: 500, headers: { "content-type": "application/json", "cache-control": "no-store" } }); } function createHeartbeatResponse(service, configErrors = []) { try { const body = createHeartbeatResponseBody(service, configErrors); return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json", "cache-control": "no-store", "x-appwarden-heartbeat": "1", "x-appwarden-contract-version": String(HEARTBEAT_CONTRACT_VERSION), "x-appwarden-service": service, "x-appwarden-version": MIDDLEWARE_VERSION } }); } catch { return createHeartbeatConstructionFailureResponse(); } } function isHeartbeatRoute(url) { return url.pathname === APPWARDEN_HEARTBEAT_ROUTE; } function isHeartbeatRequest(request, url) { return request.method === "GET" && isHeartbeatRoute(url); } function handleHeartbeatRequest(request, service, configErrors = []) { return createHeartbeatResponse(service, configErrors); } // src/utils/request-checks.ts function isHTMLResponse(response) { return response.headers.get("Content-Type")?.includes("text/html") ?? false; } function isHTMLRequest(request) { const accept = request.headers.get("accept"); if (!accept) { return false; } const normalizedAccept = accept.toLowerCase(); const isWildcardOnlyAccept = (value) => { const mediaRanges2 = value.split(","); let hasNonEmptyRange = false; for (const range of mediaRanges2) { const [typeSubtype] = range.split(";"); const trimmed = typeSubtype.trim(); if (!trimmed) { continue; } hasNonEmptyRange = true; if (trimmed !== "*/*" && trimmed !== "*") { return false; } } return hasNonEmptyRange; }; if (isWildcardOnlyAccept(normalizedAccept)) { return false; } const mediaRanges = normalizedAccept.split(","); for (const range of mediaRanges) { const [typeSubtype] = range.split(";"); const token = typeSubtype.trim(); if (token === "text/html") { return true; } } return false; } // src/utils/cloudflare/csp-keywords.ts var CSP_KEYWORDS = [ "self", "none", "unsafe-inline", "unsafe-eval", "unsafe-hashes", "strict-dynamic", "report-sample", "unsafe-allow-redirects", "wasm-unsafe-eval", "trusted-types-eval", "report-sha256", "report-sha384", "report-sha512", "unsafe-webtransport-hashes" ]; var CSP_KEYWORDS_SET = new Set(CSP_KEYWORDS); var isCSPKeyword = (value) => { return CSP_KEYWORDS_SET.has(value.toLowerCase()); }; var isQuoted = (value) => { return value.startsWith("'") && value.endsWith("'"); }; var autoQuoteCSPKeyword = (value) => { const trimmed = value.trim(); if (isQuoted(trimmed)) { return trimmed; } if (isCSPKeyword(trimmed)) { return `'${trimmed}'`; } return trimmed; }; var autoQuoteCSPDirectiveValue = (value) => { return value.trim().split(/\s+/).filter(Boolean).map(autoQuoteCSPKeyword).join(" "); }; var autoQuoteCSPDirectiveArray = (values) => { return values.map(autoQuoteCSPKeyword); }; // src/utils/cloudflare/make-csp-header.ts var addNonce = (value, cspNonce) => value.replace("{{nonce}}", `'nonce-${cspNonce}'`); var makeCSPHeader = (cspNonce, directives, mode) => { const namesSeen = /* @__PURE__ */ new Set(), result = []; Object.entries(directives ?? {}).forEach(([originalName, value]) => { const name = originalName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); if (namesSeen.has(name)) { throw new Error(`${originalName} is specified more than once`); } namesSeen.add(name); let directiveValue; if (Array.isArray(value)) { directiveValue = autoQuoteCSPDirectiveArray(value).join(" "); } else if (value === true) { directiveValue = ""; } else if (typeof value === "string") { directiveValue = autoQuoteCSPDirectiveValue(value); } else { return; } if (directiveValue) { result.push(`${name} ${addNonce(directiveValue, cspNonce)}`); } else { result.push(name); } }); return [ mode === "enforced" ? "Content-Security-Policy" : "Content-Security-Policy-Report-Only", result.join("; ") ]; }; // src/utils/get-appwarden-configuration.ts function toDirectiveRecord(value) { if (typeof value === "string") { try { const parsed = JSON.parse(value); return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; } catch { throw new Error("contentSecurityPolicy.directives must be valid JSON"); } } return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } function mergeAdapterConfig(generated, callSite) { const merged = { ...generated }; for (const key of Object.keys(callSite)) { if (key === "contentSecurityPolicy") { if (callSite[key] === void 0) { continue; } const genCsp = generated[key] || {}; const siteCsp = callSite[key] || {}; merged[key] = { mode: siteCsp.mode ?? genCsp.mode ?? void 0, directives: { ...toDirectiveRecord(genCsp.directives), ...toDirectiveRecord(siteCsp.directives) } }; } else if (callSite[key] !== void 0) { merged[key] = callSite[key]; } } return merged; } function parseMergedConfig(generatedConfig, callSiteConfig, parseSchema) { const merged = mergeAdapterConfig(generatedConfig, callSiteConfig); return parseSchema(merged); } export { buildLockPageUrl, isOnLockPage, TEMPORARY_REDIRECT_STATUS, createRedirect, printMessage, debug, MemoryCache, createHeartbeatConfigError, sanitizeConfigErrors, isHeartbeatRequest, handleHeartbeatRequest, isHTMLResponse, isHTMLRequest, makeCSPHeader, parseMergedConfig };