Stop falling back to a committed edge signing secret
`EDGE_JWT_SECRET` was read as `process.env.EDGE_JWT_SECRET ?? "dev-edge-secret-change-me"` in two places. Anything holding that string can mint an edge JWT for any `farm_id` it likes and open `/ws/edge/v1` as that farm's gateway: enough to feed a farm false telemetry and to receive the commands meant for its printers. It is in a git repository, so it is not a secret. The `??` made it worse rather than better. It only catches null and undefined, and the committed `.env.example` ships `EDGE_JWT_SECRET=` *empty* - so a deployment that copied the template and filled in everything else signed its edge JWTs with the empty string and never reached the fallback at all. That is exactly the case the fallback existed to cover. Both reads now come from `src/auth/edge_secret.ts`, which throws at module load if the variable is unset or empty. Same shape as `verifier.ts` and for the same reason: a backend that boots and then fails every gateway handshake looks like a network problem somewhere else. There is no development default on purpose - a secret that works out of the box is a secret that reaches production, and `openssl rand -hex 32` is a one-time cost. Tests: the empty-string case, since that is the one the template produced, and a grep for both shapes that reintroduce the hole - a literal passed to `jwt()`, and a defaulted read of the variable - plus an assertion that the variable is read in exactly one place. 60 pass.
This commit is contained in:
+4
-1
@@ -22,5 +22,8 @@ R2_SECRET_ACCESS_KEY=
|
|||||||
# JWKS (RS256), so no signing secret is needed - or wanted - here.
|
# JWKS (RS256), so no signing secret is needed - or wanted - here.
|
||||||
SUPABASE_URL=
|
SUPABASE_URL=
|
||||||
|
|
||||||
# Minted for continuum-proxy edge gateways via POST /auth/edge-token
|
# Signs the edge JWTs minted for continuum-proxy gateways via
|
||||||
|
# POST /auth/edge-token and verified by /ws/edge/v1. Required - the process
|
||||||
|
# refuses to start without it, because anything that knows this secret can
|
||||||
|
# connect as any farm's gateway. Generate one with: openssl rand -hex 32
|
||||||
EDGE_JWT_SECRET=
|
EDGE_JWT_SECRET=
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* The HS256 secret that signs and verifies edge-gateway JWTs, read once at
|
||||||
|
* module load so a misconfigured deployment fails at boot rather than on the
|
||||||
|
* first gateway connection. Same reasoning as `verifier.ts`, and the same
|
||||||
|
* shape deliberately.
|
||||||
|
*
|
||||||
|
* It replaces a literal `"dev-edge-secret-change-me"` fallback that was
|
||||||
|
* duplicated in `routes/auth.ts` and `routes/ws/edge.ts`. Two problems with
|
||||||
|
* that, beyond the duplication:
|
||||||
|
*
|
||||||
|
* - The fallback is in a git repository, so it is not a secret. Anything that
|
||||||
|
* knows it can mint an edge JWT for any `farm_id` it likes and open
|
||||||
|
* `/ws/edge/v1` as that farm's gateway - which is enough to feed a farm
|
||||||
|
* false telemetry and to receive the commands meant for its printers.
|
||||||
|
* - It was reached with `??`, which only catches null and undefined. The
|
||||||
|
* committed `.env.example` ships `EDGE_JWT_SECRET=` *empty*, so a
|
||||||
|
* deployment that copies the template and fills in everything else got an
|
||||||
|
* empty-string secret and no fallback at all - the one case the fallback
|
||||||
|
* was there to cover, missed.
|
||||||
|
*
|
||||||
|
* There is no development default on purpose. A secret that works out of the
|
||||||
|
* box is a secret that reaches production: `openssl rand -hex 32` into
|
||||||
|
* `.env` is a one-time cost, and unlike `SUPABASE_URL` there is nothing to
|
||||||
|
* look up to get it. Tests that need one set it before importing this.
|
||||||
|
*/
|
||||||
|
const secret = process.env.EDGE_JWT_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error(
|
||||||
|
"EDGE_JWT_SECRET is not set - it signs the edge JWTs that /ws/edge/v1 trusts, " +
|
||||||
|
"so there is no safe default. Generate one with: openssl rand -hex 32",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const edgeJwtSecret = secret;
|
||||||
+2
-1
@@ -5,6 +5,7 @@ import { UnauthorizedError } from "@continuum/ts-core";
|
|||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { farms } from "../db/schema";
|
import { farms } from "../db/schema";
|
||||||
import { listMemberships, requireUser } from "../auth";
|
import { listMemberships, requireUser } from "../auth";
|
||||||
|
import { edgeJwtSecret } from "../auth/edge_secret";
|
||||||
|
|
||||||
const EDGE_TOKEN_TTL_SECONDS = 3600;
|
const EDGE_TOKEN_TTL_SECONDS = 3600;
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ export const authRoutes = new Elysia({ prefix: "/auth" })
|
|||||||
.use(
|
.use(
|
||||||
jwt({
|
jwt({
|
||||||
name: "edgeJwt",
|
name: "edgeJwt",
|
||||||
secret: process.env.EDGE_JWT_SECRET ?? "dev-edge-secret-change-me",
|
secret: edgeJwtSecret,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { db } from "../../db";
|
import { db } from "../../db";
|
||||||
import { printers } from "../../db/schema";
|
import { printers } from "../../db/schema";
|
||||||
import { enqueueFrameIngestionBatch } from "../../queue";
|
import { enqueueFrameIngestionBatch } from "../../queue";
|
||||||
|
import { edgeJwtSecret } from "../../auth/edge_secret";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Edge-gateway uplink. A continuum-proxy gateway connects here with a
|
* Edge-gateway uplink. A continuum-proxy gateway connects here with a
|
||||||
@@ -217,7 +218,7 @@ export function isGatewayOnline(farmId: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const edgeWsRoute = new Elysia()
|
export const edgeWsRoute = new Elysia()
|
||||||
.use(jwt({ name: "edgeJwt", secret: process.env.EDGE_JWT_SECRET ?? "dev-edge-secret-change-me" }))
|
.use(jwt({ name: "edgeJwt", secret: edgeJwtSecret }))
|
||||||
.ws("/ws/edge/v1", {
|
.ws("/ws/edge/v1", {
|
||||||
body: EdgeClientMessageSchema,
|
body: EdgeClientMessageSchema,
|
||||||
|
|
||||||
|
|||||||
@@ -130,4 +130,43 @@ describe("configuration", () => {
|
|||||||
expect(await proc.exited).not.toBe(0);
|
expect(await proc.exited).not.toBe(0);
|
||||||
expect(await new Response(proc.stderr).text()).toContain("SUPABASE_URL");
|
expect(await new Response(proc.stderr).text()).toContain("SUPABASE_URL");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// An empty value, not an unset one, because that is what the committed
|
||||||
|
// `.env.example` ships: the old `process.env.EDGE_JWT_SECRET ?? "dev-..."`
|
||||||
|
// only caught null and undefined, so copying the template and filling in
|
||||||
|
// everything else left the edge JWTs signed with "". Anything that knew
|
||||||
|
// that - or the committed fallback literal - could mint a token for any
|
||||||
|
// farm_id and open /ws/edge/v1 as that farm's gateway.
|
||||||
|
test("the process refuses to start with an empty EDGE_JWT_SECRET", async () => {
|
||||||
|
const proc = Bun.spawn(["bun", "-e", "await import('./src/auth/edge_secret.ts')"], {
|
||||||
|
cwd: import.meta.dir + "/..",
|
||||||
|
env: { ...process.env, EDGE_JWT_SECRET: "" },
|
||||||
|
stderr: "pipe",
|
||||||
|
stdout: "pipe",
|
||||||
|
});
|
||||||
|
expect(await proc.exited).not.toBe(0);
|
||||||
|
expect(await new Response(proc.stderr).text()).toContain("EDGE_JWT_SECRET");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no route holds a hardcoded signing secret", async () => {
|
||||||
|
// The old fallback was duplicated across two routes, and a third copy
|
||||||
|
// would be just as exploitable as the first two, so this greps the tree
|
||||||
|
// rather than the files that happened to have it. Two shapes, because
|
||||||
|
// either one reintroduces the hole: a literal passed to `jwt()`, and a
|
||||||
|
// defaulted read of the variable (`?? "..."` / `|| "..."`), which is
|
||||||
|
// what let an empty `EDGE_JWT_SECRET` through.
|
||||||
|
const grep = async (pattern: string) => {
|
||||||
|
const proc = Bun.spawn(["grep", "-rnE", pattern, "src"], {
|
||||||
|
cwd: import.meta.dir + "/..",
|
||||||
|
stdout: "pipe",
|
||||||
|
});
|
||||||
|
return new Response(proc.stdout).text();
|
||||||
|
};
|
||||||
|
expect(await grep('secret:\\s*["\'`]')).toBe("");
|
||||||
|
expect(await grep('EDGE_JWT_SECRET\\s*(\\?\\?|\\|\\|)')).toBe("");
|
||||||
|
// And the variable is read in exactly one place, so there is only ever
|
||||||
|
// one line to get right.
|
||||||
|
expect(await grep("process\\.env\\.EDGE_JWT_SECRET")).toContain("src/auth/edge_secret.ts");
|
||||||
|
expect((await grep("process\\.env\\.EDGE_JWT_SECRET")).trim().split("\n")).toHaveLength(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user