mirror of
https://github.com/Heretek-AI/IUMBTEMS.git
synced 2026-10-12 02:03:00 +00:00
This commit is contained in:
@@ -285,7 +285,11 @@ interface HttpResponder {
|
||||
|
||||
const jsonBody = (res: HttpResponder, code: number, value: unknown): void => {
|
||||
const text = JSON.stringify(value)
|
||||
res.writeHead(code, { "content-type": "application/json", "content-length": Buffer.byteLength(text) })
|
||||
res.writeHead(code, {
|
||||
...WEB_SECURITY_HEADERS,
|
||||
"content-type": "application/json",
|
||||
"content-length": Buffer.byteLength(text),
|
||||
})
|
||||
res.end(text)
|
||||
}
|
||||
|
||||
@@ -430,7 +434,7 @@ export class TelemetryServer {
|
||||
if ((field(headers, "upgrade") ?? "").toLowerCase() === "websocket" && target === "/fleet/ws")
|
||||
return this.handleUpgrade(socket, headers, res)
|
||||
if (!this.hostOk(field(headers, "host"))) {
|
||||
res.writeHead(403, { "content-length": 0, connection: "close" })
|
||||
res.writeHead(403, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
@@ -440,7 +444,7 @@ export class TelemetryServer {
|
||||
// (bearer or session), submit is browser-only (cookie + Origin + CSRF).
|
||||
if (method === "POST" && target === "/fleet/approve/preview") return this.approvePreview(headers, rest, res)
|
||||
if (method === "POST" && target === "/fleet/approve") return this.approveSubmit(headers, rest, res)
|
||||
res.writeHead(404, { "content-length": 0, connection: "close" })
|
||||
res.writeHead(404, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
@@ -448,7 +452,7 @@ export class TelemetryServer {
|
||||
// A presented session cookie that cannot authorize is a forbidden
|
||||
// CSRF/origin failure (403); wholly missing credentials are 401.
|
||||
const code = parseSessionCookie(field(headers, "cookie")) !== undefined ? 403 : 401
|
||||
res.writeHead(code, { "content-length": 0, connection: "close" })
|
||||
res.writeHead(code, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
@@ -509,7 +513,7 @@ export class TelemetryServer {
|
||||
res.end()
|
||||
}
|
||||
if (this.options.webRoot === undefined) {
|
||||
res.writeHead(404, { "content-length": 0, connection: "close" })
|
||||
res.writeHead(404, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
@@ -580,7 +584,7 @@ export class TelemetryServer {
|
||||
// Same convention as read-only RPC: a presented session cookie that
|
||||
// cannot authorize is a forbidden CSRF/origin failure (403).
|
||||
const code = parseSessionCookie(field(headers, "cookie")) !== undefined ? 403 : 401
|
||||
res.writeHead(code, { "content-length": 0, connection: "close" })
|
||||
res.writeHead(code, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
@@ -637,12 +641,12 @@ export class TelemetryServer {
|
||||
private async approveSubmit(headers: Map<string, string>, rest: Buffer, res: HttpResponder): Promise<void> {
|
||||
const session = parseSessionCookie(field(headers, "cookie")) ?? ""
|
||||
if (!this.sessions.valid(session)) {
|
||||
res.writeHead(401, { "content-length": 0, connection: "close" })
|
||||
res.writeHead(401, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
if (!loopbackOriginOk(field(headers, "origin")) || !this.csrf.check(session, field(headers, "x-csrf"))) {
|
||||
res.writeHead(403, { "content-length": 0, connection: "close" })
|
||||
res.writeHead(403, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
@@ -730,13 +734,21 @@ export class TelemetryServer {
|
||||
|
||||
private ok(res: HttpResponder, value: unknown): void {
|
||||
const text = JSON.stringify(value)
|
||||
res.writeHead(200, { "content-type": "application/json", "content-length": Buffer.byteLength(text) })
|
||||
res.writeHead(200, {
|
||||
...WEB_SECURITY_HEADERS,
|
||||
"content-type": "application/json",
|
||||
"content-length": Buffer.byteLength(text),
|
||||
})
|
||||
res.end(text)
|
||||
}
|
||||
|
||||
private fail(res: HttpResponder, code: number, message: string, extra: Record<string, unknown> = {}): void {
|
||||
const text = JSON.stringify({ error: message, ...extra })
|
||||
res.writeHead(code, { "content-type": "application/json", "content-length": Buffer.byteLength(text) })
|
||||
res.writeHead(code, {
|
||||
...WEB_SECURITY_HEADERS,
|
||||
"content-type": "application/json",
|
||||
"content-length": Buffer.byteLength(text),
|
||||
})
|
||||
res.end(text)
|
||||
}
|
||||
|
||||
@@ -818,7 +830,7 @@ export class TelemetryServer {
|
||||
(!this.bearerOk(field(headers, "authorization")) && !cookieOk)
|
||||
) {
|
||||
const code = !this.hostOk(field(headers, "host")) || !this.originOk(field(headers, "origin")) ? 403 : 401
|
||||
res.writeHead(code, { "content-length": 0, connection: "close" })
|
||||
res.writeHead(code, { ...WEB_SECURITY_HEADERS, "content-length": 0, connection: "close" })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const frontier = {
|
||||
nodes: [{ id: "lang", question: "Language?", answer: "TypeScript", status: "settled" }],
|
||||
}
|
||||
|
||||
async function rig() {
|
||||
async function rig(extra: { approveTicketTtlMs?: number } = {}) {
|
||||
const state = await mkdtemp(path.join(tmpdir(), "es-approve-"))
|
||||
const repo = await mkdtemp(path.join(tmpdir(), "es-approve-repo-"))
|
||||
await mkdir(path.join(repo, ".factory"), { recursive: true })
|
||||
@@ -56,6 +56,7 @@ async function rig() {
|
||||
stateRoot: state,
|
||||
port: 0,
|
||||
token: TOKEN,
|
||||
...extra,
|
||||
getSnapshot: (): FleetSnapshot => ({
|
||||
daemon: { running: true, pid: 1, maxUsd: 10, concurrency: 1 },
|
||||
tasks: [],
|
||||
@@ -301,6 +302,49 @@ describe("approve submit", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("checklist 4b: an expired preview ticket fails the submit (#283.2)", async () => {
|
||||
const { state, repo, server } = await rig({ approveTicketTtlMs: 0 })
|
||||
try {
|
||||
const cookie = await session(server.port, state)
|
||||
const origin = `http://127.0.0.1:${server.port}`
|
||||
const preview = json(
|
||||
(
|
||||
await post(
|
||||
server.port,
|
||||
"/fleet/approve/preview",
|
||||
{ runId: "t1", stage: "frontier" },
|
||||
{ Cookie: cookie, Origin: origin },
|
||||
)
|
||||
).body,
|
||||
)
|
||||
// A zero TTL expires the ticket before the submit even runs: no sleep needed.
|
||||
const res = await approveAs(server.port, cookie, preview.csrf as string, {
|
||||
ticket: preview.ticket,
|
||||
passphrase: PASS,
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
expect(res.body).toContain("expired")
|
||||
const { readApproval } = await import("@heretek-ai/es-core")
|
||||
expect(await readApproval(repo, "frontier")).toBeUndefined()
|
||||
} finally {
|
||||
await server.stop()
|
||||
await rm(state, { recursive: true, force: true })
|
||||
await rm(repo, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("approve tickets expire by TTL at unit level (#283.2)", async () => {
|
||||
const { ApproveTickets } = await import("../src/approve.ts")
|
||||
const expired = new ApproveTickets(0)
|
||||
expect(
|
||||
expired.redeem(expired.issue({ repoDir: "r", stage: "frontier", subjectHash: "h" })),
|
||||
).toBeUndefined()
|
||||
const live = new ApproveTickets()
|
||||
const ticket = live.issue({ repoDir: "r", stage: "frontier", subjectHash: "h" })
|
||||
expect(live.redeem(ticket)).toEqual({ repoDir: "r", stage: "frontier", subjectHash: "h" })
|
||||
expect(live.redeem(ticket)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("checklist 5: an artifact changed after the preview refuses the sign", async () => {
|
||||
const { state, repo, server } = await rig()
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// S5.4 (CodeQL #84) remote property injection (repair-151): request headers
|
||||
// parse into a Map (never a plain `{}` keyed by remote names), non-token
|
||||
// names are rejected, and `field()` is actually case-insensitive.
|
||||
// names are rejected, and `field()` is actually case-insensitive. This file
|
||||
// probes request-header parser hygiene only — web security headers (CSP) are
|
||||
// proven in `web.test.ts` ("every web response carries the strict security
|
||||
// headers", #283.1).
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { field, parseHeaders } from "../src/telemetry.ts"
|
||||
|
||||
|
||||
@@ -36,9 +36,13 @@ async function rig() {
|
||||
return { state, webRoot, server, token }
|
||||
}
|
||||
|
||||
const raw = (port: number, head: string): Promise<{ status: number; headers: Record<string, string>; body: string }> =>
|
||||
const raw = (
|
||||
port: number,
|
||||
head: string,
|
||||
body = "",
|
||||
): Promise<{ status: number; headers: Record<string, string>; body: string }> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const socket = createConnection(port, "127.0.0.1", () => socket.write(head))
|
||||
const socket = createConnection(port, "127.0.0.1", () => socket.write(head + body))
|
||||
let buffer = Buffer.alloc(0)
|
||||
socket.on("data", (chunk: Buffer) => {
|
||||
buffer = Buffer.concat([buffer, chunk])
|
||||
@@ -67,6 +71,24 @@ const get = (port: number, target: string, headers: Record<string, string> = {})
|
||||
.join("")}\r\n`,
|
||||
)
|
||||
|
||||
const postRpc = (port: number, payload: unknown, extra: Record<string, string> = {}) => {
|
||||
const body = JSON.stringify(payload)
|
||||
return raw(
|
||||
port,
|
||||
`POST /fleet/rpc HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n${Object.entries(
|
||||
extra,
|
||||
)
|
||||
.map(([k, v]) => `${k}: ${v}\r\n`)
|
||||
.join("")}\r\n`,
|
||||
body,
|
||||
)
|
||||
}
|
||||
|
||||
const expectSecurityHeaders = (headers: Record<string, string>, where: string): void => {
|
||||
for (const [name, value] of Object.entries(WEB_SECURITY_HEADERS))
|
||||
expect(headers[name], `${where}: ${name}`).toBe(value)
|
||||
}
|
||||
|
||||
describe("web tickets", () => {
|
||||
test("a ticket redeems once, then is dead", async () => {
|
||||
const state = await mkdtemp(path.join(tmpdir(), "es-fleet-web-"))
|
||||
@@ -91,7 +113,7 @@ describe("web tickets", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("malformed and expired tickets are refused", async () => {
|
||||
test("malformed and missing tickets are refused", async () => {
|
||||
const state = await mkdtemp(path.join(tmpdir(), "es-fleet-web-"))
|
||||
try {
|
||||
expect(await redeemWebTicket(state, "not-hex")).toBe(false)
|
||||
@@ -102,6 +124,35 @@ describe("web tickets", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("a minted-then-expired ticket is refused and consumed (#283.2)", async () => {
|
||||
const state = await mkdtemp(path.join(tmpdir(), "es-fleet-web-"))
|
||||
try {
|
||||
const ticket = await mintWebTicket(state)
|
||||
const { fleetDir } = await import("../src/state.ts")
|
||||
// Age the ticket file past its TTL instead of sleeping for it.
|
||||
await writeFile(path.join(fleetDir(state), "web-tickets", ticket), `${Date.now() - 1_000}\n`)
|
||||
expect(await redeemWebTicket(state, ticket)).toBe(false)
|
||||
expect(await redeemWebTicket(state, ticket)).toBe(false)
|
||||
} finally {
|
||||
await rm(state, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("an expired ticket redeems to 403 without a cookie at HTTP level (#283.2)", async () => {
|
||||
const { state, server } = await rig()
|
||||
try {
|
||||
const ticket = await mintWebTicket(state)
|
||||
const { fleetDir } = await import("../src/state.ts")
|
||||
await writeFile(path.join(fleetDir(state), "web-tickets", ticket), `${Date.now() - 1_000}\n`)
|
||||
const res = await get(server.port, `/?t=${ticket}`)
|
||||
expect(res.status).toBe(403)
|
||||
expect(res.headers["set-cookie"]).toBeUndefined()
|
||||
} finally {
|
||||
await server.stop()
|
||||
await rm(state, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("cookie parsing extracts the session id", () => {
|
||||
const id = "ab".repeat(32)
|
||||
expect(parseSessionCookie(`a=1; ${WEB_SESSION_COOKIE}=${id}; b=2`)).toBe(id)
|
||||
@@ -164,7 +215,7 @@ describe("web serving", () => {
|
||||
})
|
||||
|
||||
test("every web response carries the strict security headers", async () => {
|
||||
const { state, server } = await rig()
|
||||
const { state, server, token } = await rig()
|
||||
try {
|
||||
const ticket = await mintWebTicket(state)
|
||||
const login = await get(server.port, `/?t=${ticket}`)
|
||||
@@ -174,6 +225,23 @@ describe("web serving", () => {
|
||||
expect(login.headers["x-content-type-options"]).toBe("nosniff")
|
||||
expect(login.headers["referrer-policy"]).toBe("no-referrer")
|
||||
expect(WEB_SECURITY_HEADERS["content-security-policy"]).toContain("connect-src 'self'")
|
||||
// JSON responders carry them too (#282): success, error envelopes, and
|
||||
// the bare 401/403/404 paths.
|
||||
const authed = { Authorization: `Bearer ${token}` }
|
||||
const ok = await postRpc(server.port, { method: "fleet.status", params: {} }, authed)
|
||||
expect(ok.status).toBe(200)
|
||||
expect(ok.headers["content-type"]).toContain("application/json")
|
||||
expectSecurityHeaders(ok.headers, "fleet.status 200")
|
||||
const unknown = await postRpc(server.port, { method: "nope", params: {} }, authed)
|
||||
expect(unknown.status).toBe(200)
|
||||
expect(unknown.body).toContain("-32601")
|
||||
expectSecurityHeaders(unknown.headers, "unknown method")
|
||||
const anon = await postRpc(server.port, { method: "fleet.status", params: {} })
|
||||
expect(anon.status).toBe(401)
|
||||
expectSecurityHeaders(anon.headers, "anonymous rpc 401")
|
||||
const missing = await raw(server.port, `GET /fleet/rpc HTTP/1.1\r\nHost: 127.0.0.1:${server.port}\r\nConnection: close\r\n\r\n`)
|
||||
expect(missing.status).toBe(404)
|
||||
expectSecurityHeaders(missing.headers, "wrong path 404")
|
||||
} finally {
|
||||
await server.stop()
|
||||
await rm(state, { recursive: true, force: true })
|
||||
|
||||
Reference in New Issue
Block a user