Files
gstack/ios-qa/daemon/test/loopback-request.test.ts
T
Garry Tanandcapy-ai[bot] 6cf00aee9c Require the daemon secret and loopback Host/Origin on the design and iOS QA daemons (#4117)
* Require the daemon secret and loopback Host/Origin on the design and iOS QA daemons (GST-1747)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Name the changelog fragment after the branch (GST-1747)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Review-Finding: p1/red-team#2 reader=red-team
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: refuse cross-site browser requests on the iOS QA loopback, show the expired page for a stale board secret, refuse requests without a Host (GST-1747)

Review-Finding: v1/outside-voice#1 reader=outside-voice
Review-Finding: v1/adversarial-native#2 reader=adversarial-native
Review-Finding: v1/adversarial-native#5 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: show the secret segment in the documented design board URL (GST-1747)

Review-Finding: v1/adversarial-native#1 reader=adversarial-native
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: keep the daemon token fixtures on one line so the credential scan reads them as code (GST-1747)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: cover the Sec-Fetch-Site refusal, the expired board page and the no-Host 403 in the changelog fragment (GST-1747)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-07 07:39:51 +00:00

96 lines
3.9 KiB
TypeScript

// Loopback listener request checks: only a loopback Host for the bound
// port is served, and a request carrying a cross-origin Origin is refused
// before anything reaches the device.
import { afterAll, beforeAll, expect, test } from 'bun:test';
import { createServer, type Server } from 'http';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { startDaemon, type RunningDaemon } from '../src/index';
let workDir: string;
let device: Server;
let daemon: RunningDaemon;
const deviceRequests: string[] = [];
beforeAll(async () => {
workDir = mkdtempSync(join(tmpdir(), 'ios-qa-loopback-request-'));
device = createServer((req, res) => {
deviceRequests.push(`${req.method} ${req.url}`);
req.resume();
req.on('end', () => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
});
await new Promise<void>((resolve) => device.listen(0, '127.0.0.1', () => resolve()));
const devicePort = (device.address() as { port: number }).port;
const d = await startDaemon({
loopbackPort: 0,
tailnetEnabled: false,
pidfilePath: join(workDir, 'daemon.pid'),
tunnelProvider: async () => ({ udid: 'STUB-UDID', ipv6Addr: '127.0.0.1', port: devicePort, bootTokenRotated: 'stub-device-token' }),
});
if ('error' in d) throw new Error(d.error);
daemon = d;
});
afterAll(async () => {
await daemon?.close();
device.close();
rmSync(workDir, { recursive: true, force: true });
});
const base = () => `http://127.0.0.1:${daemon.loopbackPort}`;
test('a non-loopback Host is refused before the device is contacted', async () => {
deviceRequests.length = 0;
for (const host of [`device.example:${daemon.loopbackPort}`, 'device.example', `127.0.0.1:${daemon.loopbackPort + 1}`, `127.0.0.2:${daemon.loopbackPort}`]) {
for (const [method, route] of [['GET', '/healthz'], ['GET', '/screenshot'], ['POST', '/type'], ['GET', '/auth/sessions']]) {
const res = await fetch(base() + route, {
method,
headers: { Host: host, 'content-type': 'text/plain' },
body: method === 'POST' ? '{}' : undefined,
});
expect({ host, route, status: res.status }).toEqual({ host, route, status: 403 });
}
}
expect(deviceRequests).toEqual([]);
});
test('a cross-origin Origin is refused before the device is contacted', async () => {
deviceRequests.length = 0;
for (const origin of ['http://device.example', 'null', `http://localhost:${daemon.loopbackPort + 1}`, `https://127.0.0.1:${daemon.loopbackPort}`]) {
const res = await fetch(base() + '/session/acquire', {
method: 'POST',
headers: { Origin: origin, 'content-type': 'text/plain' },
body: '{}',
});
expect({ origin, status: res.status }).toEqual({ origin, status: 403 });
}
expect(deviceRequests).toEqual([]);
});
test('local clients naming the loopback address still reach the device', async () => {
deviceRequests.length = 0;
for (const host of [`127.0.0.1:${daemon.loopbackPort}`, `localhost:${daemon.loopbackPort}`, `[::1]:${daemon.loopbackPort}`]) {
const res = await fetch(base() + '/screenshot', { headers: { Host: host } });
expect({ host, status: res.status }).toEqual({ host, status: 200 });
}
const sameOrigin = await fetch(base() + '/healthz', { headers: { Origin: base() } });
expect(sameOrigin.status).toBe(200);
expect(deviceRequests).toEqual(['GET /screenshot', 'GET /screenshot', 'GET /screenshot']);
});
test('a cross-site or same-site browser request without an Origin is refused before the device is contacted', async () => {
deviceRequests.length = 0;
for (const site of ['cross-site', 'same-site']) {
for (const route of ['/screenshot', '/healthz']) {
const res = await fetch(base() + route, { headers: { 'Sec-Fetch-Site': site, 'Sec-Fetch-Mode': 'no-cors' } });
expect({ site, route, status: res.status }).toEqual({ site, route, status: 403 });
}
}
expect(deviceRequests).toEqual([]);
});