From 01bcc739960a25d0c278e33b439626c5eab8ecb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Sat, 3 Oct 2026 15:21:37 +0000 Subject: [PATCH] Stop returning credentials in API responses `GET /printers` returned `accessCode` and `POST /farms` returned `edgeProxyToken`, both to callers with no authentication of any kind. Verified against a running server, not inferred: POST /farms (no auth) -> "edgeProxyToken": "a39c1709-..." GET /printers (no auth) -> {'accessCode': 'SECRET123', ...} Those are not incidental fields. `accessCode` is the printer's own LAN password, which Bambu uses for MQTT and as the FTPS password. `edgeProxyToken` trades at POST /auth/edge-token for an edge JWT, which is enough to connect to /ws/edge/v1 as that farm's gateway and send commands to its printers - a path this repo's own e2e script walks end to end. So reading one unauthenticated list was enough to take over a farm's machines. src/db/projections.ts now defines, per table, which columns may appear in a response, and the routes select those instead of `*`. Opt-in rather than a deny-list: a column added later is excluded until somebody decides it should be public, which is the safe direction to be wrong in. One helper builds both shapes Drizzle needs - the `{ col: true }` map for the relational query API and the column references for `.returning()` - from a single list of names, so the two cannot drift. `POST /farms/:id/edge-proxy-token` gives that credential one deliberate home. It rotates and returns the new value rather than revealing the stored one: a reveal endpoint hands the same secret to everyone who asks and leaves no trace, whereas rotating makes a leaked token useless and reprovisioning the fix. The cost is that it locks out a gateway still using the old token, which is the right trade for a credential that drives physical machines. This is defence in depth and NOT the fix. Every route here is still unauthenticated and nothing scopes a farm to a caller - `ownerId` and `submittedBy` are still taken from the request body, which means a client declares who owns what. That is the next piece of work and it needs a decision about the tenancy model first. --- src/db/projections.ts | 79 ++++++++++++++++++++++++++++++++++++++++++ src/routes/farms.ts | 57 +++++++++++++++++++++++++++--- src/routes/printers.ts | 21 ++++++----- 3 files changed, 145 insertions(+), 12 deletions(-) create mode 100644 src/db/projections.ts diff --git a/src/db/projections.ts b/src/db/projections.ts new file mode 100644 index 0000000..56fc4ee --- /dev/null +++ b/src/db/projections.ts @@ -0,0 +1,79 @@ +import { farms, printers } from "./schema"; + +/** + * Which columns each table is allowed to put in an API response. + * + * These exist because two tables hold credentials in ordinary-looking + * columns, and `select *` handed them out to unauthenticated callers: + * + * - `farms.edge_proxy_token` is what `POST /auth/edge-token` trades for an + * edge JWT. With it and a farm id, anyone can connect to /ws/edge/v1 as + * that farm's gateway and drive its printers. + * - `printers.access_code` is the printer's own LAN password - the same value + * Bambu uses for MQTT and as the FTPS password. + * + * Neither is needed by a client that lists or displays these records, so + * neither is listed below. Opt-in projections rather than a deny-list on + * purpose: a column added to a table later is excluded until somebody decides + * it should be public, which is the safe direction in which to be wrong. + * + * This is defence in depth, NOT authorization. It narrows what a response can + * contain and says nothing about who may ask - the routes are still + * unauthenticated. + */ + +/** + * Builds the two shapes Drizzle needs from one list of column names, so they + * cannot drift: `columns` for the relational query API + * (`db.query.x.findMany({ columns })`) and `returning` for + * `insert/update(...).returning(...)`, which wants column references rather + * than booleans. + */ +// `T extends object` rather than `Record`: a Drizzle table has +// no index signature, so the stricter constraint rejects it. +function projection(table: T, keys: readonly K[]) { + const columns = {} as { [P in K]: true }; + const returning = {} as { [P in K]: T[P] }; + for (const key of keys) { + columns[key] = true; + returning[key] = table[key]; + } + return { columns, returning }; +} + +export const farmProjection = projection(farms, [ + "id", + "name", + "ownerId", + "location", + "settings", + "createdAt", + "updatedAt", +] as const); + +export const printerProjection = projection(printers, [ + "id", + "farmId", + "name", + "vendor", + "status", + "lanAddress", + "serialNumber", + "gatewayPrinterId", + "nozzleTempC", + "nozzleTargetTempC", + "bedTempC", + "bedTargetTempC", + "progressPercent", + "currentLayer", + "totalLayers", + "remainingMinutes", + "currentJobName", + "activeAlerts", + "vendorState", + "vendorErrorCode", + "metadata", + "lastSeenAt", + "createdAt", + "updatedAt", +] as const); diff --git a/src/routes/farms.ts b/src/routes/farms.ts index 1b15551..2cc7668 100644 --- a/src/routes/farms.ts +++ b/src/routes/farms.ts @@ -2,11 +2,18 @@ import { Elysia, t } from "elysia"; import { eq } from "drizzle-orm"; import { db } from "../db"; import { farms } from "../db/schema"; +import { farmProjection } from "../db/projections"; export const farmRoutes = new Elysia({ prefix: "/farms" }) - .get("/", async () => db.select().from(farms)) + // A projection rather than `select *`: the table holds + // `edgeProxyToken`, which trades for an edge JWT and so must never leave + // the server in a list or a fetch. See src/db/projections.ts. + .get("/", async () => db.query.farms.findMany({ columns: farmProjection.columns })) .get("/:id", async ({ params, set }) => { - const farm = await db.query.farms.findFirst({ where: eq(farms.id, params.id) }); + const farm = await db.query.farms.findFirst({ + columns: farmProjection.columns, + where: eq(farms.id, params.id), + }); if (!farm) { set.status = 404; return { error: { code: "NOT_FOUND", message: `Farm ${params.id} not found` } }; @@ -16,6 +23,11 @@ export const farmRoutes = new Elysia({ prefix: "/farms" }) .post( "/", async ({ body, set }) => { + // The generated token is deliberately not returned here either. A farm + // is created far more often than a gateway is provisioned, and a + // credential that comes back from a routine create ends up in logs, + // browser history and screenshots. Fetch it when a gateway actually + // needs it, through the endpoint below. const [farm] = await db .insert(farms) .values({ @@ -24,7 +36,7 @@ export const farmRoutes = new Elysia({ prefix: "/farms" }) edgeProxyToken: crypto.randomUUID(), location: body.location, }) - .returning(); + .returning(farmProjection.returning); set.status = 201; return farm; }, @@ -35,4 +47,41 @@ export const farmRoutes = new Elysia({ prefix: "/farms" }) location: t.Optional(t.String()), }), }, - ); + ) + /** + * Issues a *new* `edgeProxyToken` for this farm and returns it once. + * + * The only route that ever returns that credential, and it rotates on every + * call rather than revealing the stored one: a reveal endpoint returns the + * same secret to everyone who asks and leaves no trace of having been + * called, while a rotate endpoint makes the old value useless, so a + * gateway's token leaking has a bounded blast radius and reprovisioning is + * the fix. + * + * The cost is that rotating locks out the gateway currently using the old + * token until it is reconfigured, which is the right trade for a credential + * that drives physical machines - but it does mean this is not a "show me + * the token" button. + * + * NOT YET AUTHENTICATED, like every route here. Until it is, this is a + * better-shaped hole, not a closed one. + */ + .post("/:id/edge-proxy-token", async ({ params, set }) => { + const token = crypto.randomUUID(); + const [farm] = await db + .update(farms) + .set({ edgeProxyToken: token, updatedAt: new Date() }) + .where(eq(farms.id, params.id)) + .returning({ id: farms.id }); + + if (!farm) { + set.status = 404; + return { error: { code: "NOT_FOUND", message: `Farm ${params.id} not found` } }; + } + + return { + farmId: farm.id, + edgeProxyToken: token, + note: "Any gateway still using the previous token must be reconfigured.", + }; + }); diff --git a/src/routes/printers.ts b/src/routes/printers.ts index db289ef..4b1b834 100644 --- a/src/routes/printers.ts +++ b/src/routes/printers.ts @@ -3,18 +3,23 @@ import { eq } from "drizzle-orm"; import { db } from "../db"; import { printers } from "../db/schema"; import { PrinterInsertSchema } from "../db/validators"; +import { printerProjection } from "../db/projections"; import { PrinterCommandSchema } from "@continuum/ts-types"; import { isGatewayOnline, sendPrinterCommand } from "./ws/edge"; export const printerRoutes = new Elysia({ prefix: "/printers" }) - .get("/", async ({ query }) => { - if (query.farmId) { - return db.select().from(printers).where(eq(printers.farmId, query.farmId)); - } - return db.select().from(printers); - }, { query: t.Object({ farmId: t.Optional(t.String({ format: "uuid" })) }) }) + // A projection rather than `select *`: `printers.accessCode` is the + // printer's LAN password (Bambu uses it for MQTT and as the FTPS password) + // and must not leave the server. See src/db/projections.ts. + .get("/", async ({ query }) => db.query.printers.findMany({ + columns: printerProjection.columns, + where: query.farmId ? eq(printers.farmId, query.farmId) : undefined, + }), { query: t.Object({ farmId: t.Optional(t.String({ format: "uuid" })) }) }) .get("/:id", async ({ params, set }) => { - const printer = await db.query.printers.findFirst({ where: eq(printers.id, params.id) }); + const printer = await db.query.printers.findFirst({ + columns: printerProjection.columns, + where: eq(printers.id, params.id), + }); if (!printer) { set.status = 404; return { error: { code: "NOT_FOUND", message: `Printer ${params.id} not found` } }; @@ -27,7 +32,7 @@ export const printerRoutes = new Elysia({ prefix: "/printers" }) // `body` is already validated + shaped against the Drizzle table by // PrinterInsertSchema, so it can go straight into `.values()` with no // hand-mapping of individual fields. - const [printer] = await db.insert(printers).values(body).returning(); + const [printer] = await db.insert(printers).values(body).returning(printerProjection.returning); set.status = 201; return printer; },