diff --git a/.changeset/rotten-donuts-cough.md b/.changeset/rotten-donuts-cough.md new file mode 100644 index 000000000..d4baa5d27 --- /dev/null +++ b/.changeset/rotten-donuts-cough.md @@ -0,0 +1,14 @@ +--- +"@valbuild/server": patch +"@valbuild/shared": patch +"@valbuild/ui": patch +"@valbuild/cli": patch +"@valbuild/core": patch +"@valbuild/create": patch +"@valbuild/eslint-plugin": patch +"@valbuild/init": patch +"@valbuild/next": patch +"@valbuild/react": patch +--- + +Add admin status endpoint diff --git a/packages/server/src/ValServer.ts b/packages/server/src/ValServer.ts index ca016c6ca..9ad87f983 100644 --- a/packages/server/src/ValServer.ts +++ b/packages/server/src/ValServer.ts @@ -48,6 +48,7 @@ import { import path from "path"; import { hasRemoteFileSchema } from "./hasRemoteFileSchema"; import { ReifiedRender } from "@valbuild/core"; +import { VERSION as uiVersion } from "@valbuild/ui"; export type ValServerOptions = { route: string; @@ -364,8 +365,159 @@ export const ValServer = ( json: { remoteFileAuth }, }; }; + const serverVersion = ((): string | null => { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require("../package.json").version; + } catch { + return null; + } + })(); return { + "/admin/status": { + POST: async (req) => { + // This endpoint is the only that uses only the API key to authenticate. + // The reason is that it is called by the admin app to get the status of the current build and the admin app only has the api key + // The status information is not very sensitive but we figured it is might be used for reconnaissance, so we're taking extra precautions to protect against brute force attacks and timing attacks... + + // Input validation + if ( + !req.body?.apiKey || + !req.body?.code || + typeof req.body.apiKey !== "string" || + typeof req.body.code !== "string" + ) { + return { status: 400, json: { message: "Invalid request" } }; + } + + const getStatus = () => { + const versions = { + core: Internal.VERSION.core || "unknown", + ui: uiVersion, + server: serverVersion || "unknown", + }; + if (options.mode === "fs") { + return { + versions, + mode: options.mode, + project: options.project, + }; + } + return { + versions, + mode: options.mode, + project: options.project, + commit: options.commit, + branch: options.branch, + root: options.root, + }; + }; + // We do NOT check the api key prior to sending the request, because it would open us up to a timing attack + const clientCode = crypto.randomUUID(); + const url = new URL(`/api/val/status`, options.valBuildUrl); + type Versions = { + core: string; + ui: string; + server: string; + }; + const statusData = getStatus(); + const body: + | { + versions: Versions; + mode: "fs"; + project: string | undefined; + timestamp: number; + request_code: string; + client_code: string; + } + | { + mode: "http"; + versions: Versions; + root: string | undefined; + project: string | undefined; + commit: string | undefined; + branch: string | undefined; + timestamp: number; + request_code: string; + client_code: string; + } = { + ...statusData, + timestamp: Date.now(), + request_code: req.body.code, + client_code: clientCode, + }; + + // Network request with timeout and error handling + let res, resBody; + try { + const controller = new AbortController(); + setTimeout(() => controller.abort(), 30000); // 30s timeout + + res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${req.body.apiKey}`, + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + resBody = await res.json(); + } catch (error) { + return { + status: 503, + json: { message: "Service temporarily unavailable" }, + }; + } + + // Response validation + if (!resBody || typeof resBody !== "object") { + return { + status: 502, + json: { message: "Invalid response from service" }, + }; + } + + // Bounded delay to prevent DDoS + const delay = Math.min(Math.max(resBody.delay || 0, 0), 10000); // Cap at 10 seconds + await new Promise((resolve) => setTimeout(resolve, delay)); + + // Safe client code validation + if (!resBody.client_code || resBody.client_code !== clientCode) { + return { + status: 401, + json: { + message: "Unauthorized", + }, + }; + } + + if (res.status === 401) { + return { + status: 401, + json: { + message: "Unauthorized", + }, + }; + } + + if (res.status !== 200) { + return { + status: 500, + json: { + message: "Internal server error", + }, + }; + } + return { + status: 200, + json: { + ...statusData, + }, + }; + }, + }, "/draft/enable": { GET: async (req) => { const cookies = req.cookies; diff --git a/packages/shared/src/internal/ApiRoutes.ts b/packages/shared/src/internal/ApiRoutes.ts index e4f90e5b3..b9ba6f9cd 100644 --- a/packages/shared/src/internal/ApiRoutes.ts +++ b/packages/shared/src/internal/ApiRoutes.ts @@ -125,6 +125,59 @@ const onlyOneBooleanQueryParam = onlyOneStringQueryParam .transform((arg) => arg === "true"); export const Api = { + "/admin/status": { + POST: { + req: { + body: z.object({ + apiKey: z.string(), + code: z.string(), + }), + cookies: {}, + }, + res: z.union([ + unauthorizedResponse, + z.object({ + status: z.literal(200), + json: z.object({ + versions: z.object({ + core: z.string(), + ui: z.string(), + server: z.string(), + }), + mode: z.union([z.literal("fs"), z.literal("http")]), + project: z.string().optional(), + commit: z.string().optional(), + branch: z.string().optional(), + root: z.string().optional(), + }), + }), + z.object({ + status: z.literal(400), + json: z.object({ + message: z.string(), + }), + }), + z.object({ + status: z.literal(500), + json: z.object({ + message: z.string(), + }), + }), + z.object({ + status: z.literal(502), + json: z.object({ + message: z.string(), + }), + }), + z.object({ + status: z.literal(503), + json: z.object({ + message: z.string(), + }), + }), + ]), + }, + }, "/draft/enable": { GET: { req: {