Skip to content

Commit b4267b4

Browse files
committed
fix(ci): treat LXD auth challenge as safe
1 parent 1161c4a commit b4267b4

5 files changed

Lines changed: 159 additions & 32 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { assertSafeLxdApiRootResponse } from "./integration/scenarios/common";
3+
import type { HttpsResponse } from "./integration/assertions/http";
4+
5+
function response(status: number, body = "", headers = ""): HttpsResponse {
6+
return { status, body, headers };
7+
}
8+
9+
const lxdRootBody = JSON.stringify({
10+
metadata: {
11+
api_extensions: ["oidc"],
12+
auth: "untrusted"
13+
}
14+
});
15+
16+
describe("LXD API public probe", () => {
17+
test("accepts unauthenticated LXD JSON when it is not trusted", () => {
18+
expect(() => assertSafeLxdApiRootResponse(response(200, lxdRootBody), "lxd.example.test", "auth.example.test")).not.toThrow();
19+
});
20+
21+
test("rejects trusted anonymous LXD JSON", () => {
22+
const body = JSON.stringify({ metadata: { api_extensions: [], auth: "trusted" } });
23+
24+
expect(() => assertSafeLxdApiRootResponse(response(200, body), "lxd.example.test", "auth.example.test")).toThrow("trusted anonymous");
25+
});
26+
27+
test("accepts expected OIDC challenges without following interactive redirects", () => {
28+
expect(() =>
29+
assertSafeLxdApiRootResponse(response(302, "", "HTTP/2 302\r\nlocation: /oidc/login\r\n"), "lxd.example.test", "auth.example.test")
30+
).not.toThrow();
31+
expect(() =>
32+
assertSafeLxdApiRootResponse(
33+
response(303, "", "HTTP/2 303\r\nlocation: https://auth.example.test/oauth/v2/authorize\r\n"),
34+
"lxd.example.test",
35+
"auth.example.test"
36+
)
37+
).not.toThrow();
38+
expect(() => assertSafeLxdApiRootResponse(response(403), "lxd.example.test", "auth.example.test")).not.toThrow();
39+
});
40+
41+
test("rejects redirects away from the managed LXD and auth hosts", () => {
42+
expect(() =>
43+
assertSafeLxdApiRootResponse(response(302, "", "location: https://evil.example.test/login\r\n"), "lxd.example.test", "auth.example.test")
44+
).toThrow("unexpected location");
45+
});
46+
});

tests/integration-scenario-timeouts.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ describe("integration scenario timeout guardrails", () => {
88
expect(commonSource).toContain("verify ${host.label} LXD API");
99
expect(commonSource).toContain("verified ${host.label} LXD API");
1010
expect(commonSource).toContain("LXD API verification for ${host.label}");
11-
expect(commonSource).toContain("timeoutMs: LXD_API_POLL_TIMEOUT_MS");
11+
expect(commonSource).toContain("Date.now() + LXD_API_POLL_TIMEOUT_MS");
1212
expect(commonSource).toContain("external OIDC LXD API for ${host.label}");
1313
expect(commonSource).toContain("local ZITADEL LXD API for ${host.label}");
1414
expect(commonSource.match(/verifyLxdApi\(host, context\)/g)?.length).toBe(2);

tests/integration/assertions/http.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,6 @@ describe("HTTP assertion helpers", () => {
3232
expect(source.match(/timeoutMs: CURL_PROCESS_TIMEOUT_MS/g)?.length).toBe(2);
3333
expect(source.match(/await fetchWithTimeout\(url/g)?.length).toBe(1);
3434
expect(source.match(/await fetchTextWithTimeout\(url/g)?.length).toBe(1);
35+
expect(source).toContain("...(options.followRedirects ? [\"-L\"] : [])");
3536
});
3637
});

tests/integration/assertions/http.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ type HttpAssertionOptions = {
99
insecure?: boolean;
1010
};
1111

12+
type HttpsReadOptions = HttpAssertionOptions & {
13+
followRedirects?: boolean;
14+
headers?: string[];
15+
};
16+
17+
export type HttpsResponse = {
18+
status: number;
19+
body: string;
20+
headers: string;
21+
};
22+
1223
type JsonValidator = (value: unknown) => void;
1324

1425
export const HTTP_FETCH_TIMEOUT_MS = 20000;
@@ -74,17 +85,21 @@ export async function waitForHttpStatus(url: string, expectedStatuses: number[],
7485
throw new Error(`timed out waiting for ${url} to return one of [${expectedStatuses.join(", ")}], last status: ${lastResponse?.status ?? "none"}`);
7586
}
7687

77-
async function readHttpsBody(url: string, options: HttpAssertionOptions = {}): Promise<{ status: number; body: string }> {
88+
export async function readHttpsResponse(url: string, options: HttpsReadOptions = {}): Promise<HttpsResponse> {
7889
const tempDir = await mkdtemp(join(tmpdir(), "terrarium-http-"));
7990
const bodyPath = join(tempDir, "body");
91+
const headersPath = join(tempDir, "headers");
8092
try {
8193
const result = await runAllowFailure([
8294
"curl",
8395
"-4",
8496
"-sS",
85-
"-L",
8697
"--noproxy",
8798
"*",
99+
...(options.followRedirects ? ["-L"] : []),
100+
...((options.headers ?? []).flatMap((header) => ["-H", header])),
101+
"-D",
102+
headersPath,
88103
"-o",
89104
bodyPath,
90105
"-w",
@@ -102,12 +117,18 @@ async function readHttpsBody(url: string, options: HttpAssertionOptions = {}): P
102117
}
103118

104119
const body = await readFile(bodyPath, "utf8");
105-
return parseCurlHttpBodyResult(result.stdout, body);
120+
const headers = await readFile(headersPath, "utf8").catch(() => "");
121+
return { ...parseCurlHttpBodyResult(result.stdout, body), headers };
106122
} finally {
107123
await rm(tempDir, { recursive: true, force: true });
108124
}
109125
}
110126

127+
async function readHttpsBody(url: string, options: HttpsReadOptions = {}): Promise<{ status: number; body: string }> {
128+
const { status, body } = await readHttpsResponse(url, options);
129+
return { status, body };
130+
}
131+
111132
/** Polls an HTTPS endpoint with certificate verification disabled until it returns an expected status. */
112133
export async function waitForHttpStatusInsecure(
113134
url: string,
@@ -187,7 +208,7 @@ export async function expectHttpBodyContains(
187208
while (Date.now() < deadline) {
188209
try {
189210
if (url.startsWith("https://")) {
190-
const { status, body } = await readHttpsBody(url, { resolveIp, insecure });
211+
const { status, body } = await readHttpsBody(url, { resolveIp, insecure, followRedirects: true });
191212
lastStatus = String(status || 0);
192213
lastBody = body;
193214
if (body.includes(needle)) {

tests/integration/scenarios/common.ts

Lines changed: 86 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { createHash, randomUUID } from "node:crypto";
55
import { IntegrationContext } from "../context";
66
import type { ExternalOidcFixture, ManagedHost, VolumeRecord } from "../types";
77
import { SshHost } from "../remote/ssh";
8-
import { expectHttpBodyContains, expectHttpsJson, waitForHttpStatusResolved } from "../assertions/http";
8+
import { expectHttpBodyContains, readHttpsResponse, waitForHttpStatusResolved, type HttpsResponse } from "../assertions/http";
99
import { expectLxdUi, expectManagementSurfaces, expectManagementUi, expectProtectedRoute } from "../assertions/browser";
1010
import { expectRemoteContains, expectSystemdActive } from "../assertions/host";
1111
import { collectHostArtifacts } from "../cleanup";
@@ -330,36 +330,95 @@ export async function verifyManagementSurfaces(
330330
export async function verifyLxdApi(host: ManagedHost, context?: IntegrationContext): Promise<void> {
331331
await withStepTimeout(`LXD API verification for ${host.label}`, LXD_API_VERIFY_TIMEOUT_MS, async () => {
332332
context?.logger.info(`verify ${host.label} LXD API`);
333-
await expectHttpsJson(
334-
`https://${host.domains.lxd}/1.0`,
335-
(body) => {
336-
if (!isObject(body)) {
337-
throw new Error("LXD API root did not return an object");
338-
}
339-
340-
const metadata = body.metadata;
341-
if (!isObject(metadata)) {
342-
throw new Error("LXD API root did not include metadata");
343-
}
344-
345-
if (!Array.isArray(metadata.api_extensions)) {
346-
throw new Error("LXD API root did not include api_extensions");
347-
}
348-
349-
const auth = typeof metadata.auth === "string" ? metadata.auth.toLowerCase() : "";
350-
if (!auth) {
351-
throw new Error("LXD API root did not include auth state");
352-
}
353-
if (auth === "trusted") {
354-
throw new Error("LXD API root allowed trusted anonymous access");
355-
}
356-
},
357-
{ timeoutMs: LXD_API_POLL_TIMEOUT_MS, resolveIp: host.server.ipv4 }
358-
);
333+
const response = await waitForLxdApiRootResponse(host);
334+
assertSafeLxdApiRootResponse(response, host.domains.lxd, host.domains.auth);
359335
context?.logger.info(`verified ${host.label} LXD API`);
360336
});
361337
}
362338

339+
async function waitForLxdApiRootResponse(host: ManagedHost): Promise<HttpsResponse> {
340+
const deadline = Date.now() + LXD_API_POLL_TIMEOUT_MS;
341+
let lastError = "";
342+
while (Date.now() < deadline) {
343+
try {
344+
return await readHttpsResponse(`https://${host.domains.lxd}/1.0`, {
345+
resolveIp: host.server.ipv4,
346+
headers: ["Accept: application/json"]
347+
});
348+
} catch (error) {
349+
lastError = error instanceof Error ? error.message : String(error);
350+
await Bun.sleep(5000);
351+
}
352+
}
353+
354+
throw new Error(`timed out waiting for LXD API root; last error=${lastError || "none"}`);
355+
}
356+
357+
export function assertSafeLxdApiRootResponse(response: HttpsResponse, lxdHost: string, authHost?: string): void {
358+
if ([301, 302, 303, 307, 308].includes(response.status)) {
359+
const location = response.headers.match(/^location:\s*(.+)$/im)?.[1]?.trim() ?? "";
360+
if (isExpectedLxdAuthRedirect(location, lxdHost, authHost)) {
361+
return;
362+
}
363+
throw new Error(`LXD API root redirected to unexpected location: ${location || "<missing>"}`);
364+
}
365+
366+
if ([401, 403].includes(response.status)) {
367+
return;
368+
}
369+
370+
if (response.status < 200 || response.status >= 300) {
371+
throw new Error(`LXD API root returned unexpected HTTP status ${response.status}`);
372+
}
373+
374+
let body: unknown;
375+
try {
376+
body = JSON.parse(response.body) as unknown;
377+
} catch {
378+
throw new Error(`LXD API root did not return JSON; body=${response.body.replace(/\s+/g, " ").trim().slice(0, 400) || "<empty>"}`);
379+
}
380+
381+
if (!isObject(body)) {
382+
throw new Error("LXD API root did not return an object");
383+
}
384+
385+
const metadata = body.metadata;
386+
if (!isObject(metadata)) {
387+
throw new Error("LXD API root did not include metadata");
388+
}
389+
390+
if (!Array.isArray(metadata.api_extensions)) {
391+
throw new Error("LXD API root did not include api_extensions");
392+
}
393+
394+
const auth = typeof metadata.auth === "string" ? metadata.auth.toLowerCase() : "";
395+
if (!auth) {
396+
throw new Error("LXD API root did not include auth state");
397+
}
398+
if (auth === "trusted") {
399+
throw new Error("LXD API root allowed trusted anonymous access");
400+
}
401+
}
402+
403+
function isExpectedLxdAuthRedirect(location: string, lxdHost: string, authHost?: string): boolean {
404+
if (!location) {
405+
return false;
406+
}
407+
408+
let target: URL;
409+
try {
410+
target = new URL(location, `https://${lxdHost}`);
411+
} catch {
412+
return false;
413+
}
414+
415+
if (target.host === lxdHost) {
416+
return target.pathname.startsWith("/oidc/") || target.pathname.startsWith("/ui/");
417+
}
418+
419+
return Boolean(authHost && target.host === authHost);
420+
}
421+
363422
/** Verifies a real browser login through LXD's public OIDC flow. */
364423
export async function verifyLxdUi(
365424
context: IntegrationContext,

0 commit comments

Comments
 (0)