Public: anyone can read this; the crew can change it.
(0) Trust: test the parent-answer byte caps at register and token, and the parent-made account's SSH sign-in end to end #2326
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2289-0-trust-test-the-parent-answer-byte-caps → main 1 commit fork
1 file
changed +108
−0
test/forge-signin.test.ts +108
| @@ -11,6 +11,8 @@ import { join } from "node:path"; | ||
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 12 | import { createSession } from "../src/core/sessions.ts"; |
| 13 | 13 | import { createUser, getUserByName, verifyPassword } from "../src/core/users.ts"; |
| 14 | import { keepClient, CALLBACK_PATH } from "../src/core/federation.ts"; | |
| 15 | import { forgeSshSession } from "../src/tui/ssh.ts"; | |
| 14 | 16 | import { addKey, findKeyByFingerprint, fingerprint, parseAuthorizedKey } from "../src/core/keys.ts"; |
| 15 | 17 | import { KeySet } from "../src/http/jwks.ts"; |
| 16 | 18 | import { serve, type Servers } from "../src/http/server.ts"; |
| @@ -242,6 +244,43 @@ describe("sign in through the parent forge", () => { | ||
| 242 | 244 | if (!parsed.ok) throw new Error("key"); |
| 243 | 245 | expect(addKey(child.db, local.id, "laptop", parsed.value).ok).toBe(true); |
| 244 | 246 | expect(findKeyByFingerprint(child.db, fingerprint(parsed.value.blob))?.userId).toBe(local.id); |
| 247 | ||
| 248 | // And the terminal itself: a shell opened with that key is that account's. | |
| 249 | let screen = ""; | |
| 250 | const closers: (() => void)[] = []; | |
| 251 | const session = { | |
| 252 | key: { fingerprint: fingerprint(parsed.value.blob), line }, | |
| 253 | username: "whoever", | |
| 254 | remoteAddress: "203.0.113.9", | |
| 255 | kind: "shell" as const, | |
| 256 | command: undefined, | |
| 257 | term: "xterm-256color", | |
| 258 | env: {}, | |
| 259 | cols: 100, | |
| 260 | rows: 30, | |
| 261 | widthPx: 0, | |
| 262 | heightPx: 0, | |
| 263 | ended: false, | |
| 264 | pending: 0, | |
| 265 | write(data: Uint8Array | string) { | |
| 266 | screen += typeof data === "string" ? data : Buffer.from(data).toString(); | |
| 267 | }, | |
| 268 | writeStderr() {}, | |
| 269 | onData() {}, | |
| 270 | onResize() {}, | |
| 271 | onEof() {}, | |
| 272 | onClose(cb: () => void) { | |
| 273 | closers.push(cb); | |
| 274 | }, | |
| 275 | exit() { | |
| 276 | for (const cb of closers) cb(); | |
| 277 | }, | |
| 278 | drained: async () => {}, | |
| 279 | }; | |
| 280 | forgeSshSession(child)(session as never); | |
| 281 | await Bun.sleep(300); | |
| 282 | session.exit(); | |
| 283 | expect(screen).toContain("alice"); | |
| 245 | 284 | }); |
| 246 | 285 | |
| 247 | 286 | test("starting a sign-in is throttled, so a flood cannot push out those in flight", async () => { |
| @@ -387,3 +426,72 @@ describe("a parent's oversized answer", () => { | ||
| 387 | 426 | expect(pulled.n).toBeLessThan(200); |
| 388 | 427 | }); |
| 389 | 428 | }); |
| 429 | ||
| 430 | describe("a parent's oversized register and token answers", () => { | |
| 431 | const realFetch = globalThis.fetch; | |
| 432 | const PARENT = "http://parent.invalid"; | |
| 433 | let dir = ""; | |
| 434 | let child: App; | |
| 435 | let servers: Servers; | |
| 436 | let pulled = { n: 0 }; | |
| 437 | let streaming: "register" | "token" | "" = ""; | |
| 438 | ||
| 439 | /** A body that never ends, counting how many chunks were pulled from it. */ | |
| 440 | const endless = (): Response => { | |
| 441 | pulled = { n: 0 }; | |
| 442 | return new Response( | |
| 443 | new ReadableStream<Uint8Array>({ | |
| 444 | pull(controller) { | |
| 445 | pulled.n++; | |
| 446 | controller.enqueue(new Uint8Array(1024).fill(32)); | |
| 447 | }, | |
| 448 | }), | |
| 449 | { status: 201, headers: { "content-type": "application/json" } }, | |
| 450 | ); | |
| 451 | }; | |
| 452 | ||
| 453 | beforeAll(async () => { | |
| 454 | dir = await mkdtemp(join(tmpdir(), "forge-signin-")); | |
| 455 | // The parent is this stub; every other address is reached as usual. | |
| 456 | globalThis.fetch = ((input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => { | |
| 457 | const url = String(input instanceof Request ? input.url : input); | |
| 458 | if (url === `${PARENT}/oauth/register` && streaming === "register") return Promise.resolve(endless()); | |
| 459 | if (url === `${PARENT}/oauth/token` && streaming === "token") return Promise.resolve(endless()); | |
| 460 | return realFetch(input, init); | |
| 461 | }) as typeof fetch; | |
| 462 | child = createApp({ dataDir: dir, port: 0, parentUrl: PARENT, parentLoopback: true }); | |
| 463 | servers = await serve(child); | |
| 464 | }); | |
| 465 | afterAll(async () => { | |
| 466 | globalThis.fetch = realFetch; | |
| 467 | servers.stop(); | |
| 468 | await rm(dir, { recursive: true, force: true }); | |
| 469 | }); | |
| 470 | ||
| 471 | test("a registration that streams forever is a 502, and few chunks are pulled", async () => { | |
| 472 | streaming = "register"; | |
| 473 | const res = await fetch(`${servers.url}/signin/parent`, { redirect: "manual" }); | |
| 474 | expect(res.status).toBe(502); | |
| 475 | expect(pulled.n).toBeGreaterThan(0); | |
| 476 | expect(pulled.n).toBeLessThan(100); | |
| 477 | expect(child.db.query("SELECT COUNT(*) AS n FROM rp_client").get()).toEqual({ n: 0 }); | |
| 478 | }); | |
| 479 | ||
| 480 | test("a token answer that streams forever is a 502 with no session, and few chunks are pulled", async () => { | |
| 481 | streaming = ""; | |
| 482 | keepClient(child.db, `${PARENT}`, `${servers.url}${CALLBACK_PATH}`, "client-1"); | |
| 483 | const start = await fetch(`${servers.url}/signin/parent`, { redirect: "manual" }); | |
| 484 | expect(start.status).toBe(303); | |
| 485 | const cookie = start.headers.getSetCookie()[0]!.split(";")[0]!; | |
| 486 | const state = cookie.slice("forge_rp=".length); | |
| 487 | streaming = "token"; | |
| 488 | const res = await fetch(`${servers.url}${CALLBACK_PATH}?code=abc&state=${state}`, { | |
| 489 | redirect: "manual", | |
| 490 | headers: { cookie }, | |
| 491 | }); | |
| 492 | expect(res.status).toBe(502); | |
| 493 | expect(res.headers.getSetCookie().some((c) => c.startsWith("forge_session=") && !c.includes("Max-Age=0"))).toBe(false); | |
| 494 | expect(pulled.n).toBeGreaterThan(0); | |
| 495 | expect(pulled.n).toBeLessThan(100); | |
| 496 | }); | |
| 497 | }); |
new above
offline