89 lines
3.0 KiB
TypeScript
89 lines
3.0 KiB
TypeScript
export interface GraphqlRequest {
|
|
query: string;
|
|
variables?: Record<string, unknown>;
|
|
operationName?: string;
|
|
}
|
|
export interface GraphqlExecutionResult {
|
|
data?: unknown;
|
|
errors?: Array<{ message: string; [key: string]: unknown }>;
|
|
}
|
|
export interface GraphqlOptions {
|
|
execute(
|
|
request: GraphqlRequest,
|
|
context: { request: Request; signal: AbortSignal },
|
|
): GraphqlExecutionResult | Promise<GraphqlExecutionResult>;
|
|
maxQueryBytes?: number;
|
|
maxDepth?: number;
|
|
maxAliases?: number;
|
|
allowIntrospection?: boolean;
|
|
}
|
|
|
|
function queryMetrics(query: string) {
|
|
let depth = 0,
|
|
maxDepth = 0,
|
|
aliases = 0;
|
|
let string = false,
|
|
escaped = false;
|
|
for (let index = 0; index < query.length; index++) {
|
|
const char = query[index]!;
|
|
if (string) {
|
|
if (escaped) escaped = false;
|
|
else if (char === "\\") escaped = true;
|
|
else if (char === '"') string = false;
|
|
continue;
|
|
}
|
|
if (char === '"') string = true;
|
|
else if (char === "{") maxDepth = Math.max(maxDepth, ++depth);
|
|
else if (char === "}") depth--;
|
|
else if (char === ":" && /[A-Za-z0-9_]\s*$/.test(query.slice(Math.max(0, index - 40), index)))
|
|
aliases++;
|
|
}
|
|
return { depth: maxDepth, aliases };
|
|
}
|
|
|
|
export function createGraphqlHandler(options: GraphqlOptions) {
|
|
return async (request: Request): Promise<Response> => {
|
|
if (request.method !== "POST")
|
|
return new Response("Method Not Allowed", { status: 405, headers: { allow: "POST" } });
|
|
const maxBytes = options.maxQueryBytes ?? 100_000;
|
|
const length = Number(request.headers.get("content-length") ?? 0);
|
|
if (length > maxBytes)
|
|
return Response.json(
|
|
{ errors: [{ message: "GraphQL query is too large" }] },
|
|
{ status: 413 },
|
|
);
|
|
const body = (await request.json().catch(() => null)) as GraphqlRequest | null;
|
|
if (
|
|
!body ||
|
|
typeof body.query !== "string" ||
|
|
new TextEncoder().encode(body.query).length > maxBytes
|
|
)
|
|
return Response.json(
|
|
{ errors: [{ message: "A bounded GraphQL query string is required" }] },
|
|
{ status: 400 },
|
|
);
|
|
if (options.allowIntrospection === false && /\b__(?:schema|type)\b/.test(body.query))
|
|
return Response.json(
|
|
{ errors: [{ message: "GraphQL introspection is disabled" }] },
|
|
{ status: 403 },
|
|
);
|
|
const metrics = queryMetrics(body.query);
|
|
if (metrics.depth > (options.maxDepth ?? 12) || metrics.aliases > (options.maxAliases ?? 50))
|
|
return Response.json(
|
|
{ errors: [{ message: "GraphQL query complexity limit exceeded" }] },
|
|
{ status: 400 },
|
|
);
|
|
try {
|
|
const result = await options.execute(body, { request, signal: request.signal });
|
|
return Response.json(result, {
|
|
status: result.errors?.length && result.data === undefined ? 400 : 200,
|
|
headers: { "cache-control": "no-store" },
|
|
});
|
|
} catch {
|
|
return Response.json({ errors: [{ message: "GraphQL execution failed" }] }, { status: 500 });
|
|
}
|
|
};
|
|
}
|
|
|
|
export { graphqlPlugin } from "./plugin.ts";
|