Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/fleet/enrol-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ import { resolveMachineId, type MachineIdentityDeps } from "./machine-identity.j

export const ENROL_PATH = "/api/agent/enrol";
const DEFAULT_TIMEOUT_MS = 30_000;

/**
* The longest token this will accept and write to disk.
*
* A real one is `cgm_` plus 32 base64url bytes — under fifty characters. The
* bound exists because the value comes from whatever server the operator
* pointed at: without it, a hostile or broken one can return a gigabyte of
* string and this writes all of it into the user's home directory. Generous
* enough that a longer token format would still fit.
*/
const MAX_TOKEN_LENGTH = 1024;
/** The token is a fleet credential; nobody else on the machine needs it. */
const FILE_MODE = 0o600;
const DIR_MODE = 0o700;
Expand Down Expand Up @@ -90,6 +101,13 @@ export async function enrolMachine(
return { ok: false, reason: `${url} did not return a token` };
}

if (body.token.length > MAX_TOKEN_LENGTH) {
return {
ok: false,
reason: `${url} returned a token of ${body.token.length} characters, which is not a token this server should be issuing`,
};
}

const configPath = fleetConfigPath(deps);
const contents = `${JSON.stringify({ server, token: body.token }, null, 2)}\n`;

Expand Down
27 changes: 26 additions & 1 deletion tests/fleet/enrol-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdtempSync, readFileSync, statSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
Expand Down Expand Up @@ -201,6 +201,31 @@ describe("enrolMachine", () => {
expect(result.reason).toContain("did not return a token");
});

// The token is whatever the server sends, and this writes it into the
// user's home directory. A hostile or broken server returning an enormous
// string should be refused rather than persisted.
it("refuses a token far larger than any real one, instead of writing it", async () => {
const home = tempHome();
const huge = `cgm_${"A".repeat(200_000)}`;

const result = await enrolMachine(
{ server: "https://g.example", code: "C" },
{
homeDir: () => home,
fetch: (async () =>
new Response(JSON.stringify({ token: huge }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch,
},
);

expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toContain("not a token this server should be issuing");
expect(existsSync(fleetConfigPath({ homeDir: () => home }))).toBe(false);
});

it("says so when enrolment worked but the config could not be written", async () => {
const result = await enrolMachine(
{ server: "https://g.example", code: "C" },
Expand Down