mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-08 01:38:10 +01:00
* 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>
96 lines
3.9 KiB
TypeScript
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([]);
|
|
});
|