@appwarden/middleware
Version:
Instantly disable all user interaction with your app deployed on Cloudflare or Vercel
557 lines (545 loc) • 17.6 kB
JavaScript
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
};