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.
This commit is contained in:
2026-10-03 15:21:37 +00:00
parent d827b465cf
commit 01bcc73996
3 changed files with 145 additions and 12 deletions
+79
View File
@@ -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<string, unknown>`: a Drizzle table has
// no index signature, so the stricter constraint rejects it.
function projection<T extends object, K extends keyof T>(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);