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:
@@ -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);
|
||||
Reference in New Issue
Block a user