Files
gstack/browse/test/server-factory.test.ts
T
Garry Tan 149fe74349 fix: harden auth-token validation, TDZ try/catch, lockfile path safety
Three security hardening fixes from /ship adversarial review:

1. AUTH_TOKEN unicode-whitespace bypass (server.ts:67-83).
   Old: `process.env.AUTH_TOKEN?.trim() || randomUUID()` only stripped
   ASCII whitespace. A misconfigured embedder shipping AUTH_TOKEN=$''
   (BOM) or $'​' (zero-width space) would silently get a
   one-character bearer secret. New `sanitizeAuthToken()` strips all
   unicode whitespace via regex and requires >= 16 chars after stripping;
   anything shorter falls back to crypto.randomUUID(). Same sanitizer
   used by `resolveConfigFromEnv()` so the embedder path is hardened too.

2. security-classifier.ts checkTranscript safety net.
   `resolveClaudeCommand()` and `spawn()` can throw under transient
   conditions (PATH probe failure, posix_spawn ENOMEM). Old code let the
   throw propagate and rejected the Promise with a raw exception. Now
   wrapped in try/catch that calls finish() with a degraded signal,
   matching the graceful-degradation contract the layer already promises
   for missing-CLI / exit-nonzero / parse-error.

3. cleanSingletonLocks defensive guard tightened (config.ts).
   Old: basename === 'chromium-profile' OR userDataDir === $CHROMIUM_PROFILE.
   The second branch was env-controlled and the first was bypassable by
   passing a relative path that resolved to chromium-profile via CWD
   drift. New guard: refuses relative paths outright, resolves both
   sides via path.resolve(), and only accepts the env-match path when
   $CHROMIUM_PROFILE is itself absolute.

Test updates: replace the old `.trim()` test with three new cases
covering unicode-whitespace stripping, short-token rejection, and
zero-width-only rejection (server-factory.test.ts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:05:39 -07:00

194 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, test, expect } from 'bun:test';
import {
resolveConfigFromEnv,
type ServerConfig,
type ServerHandle,
type Surface,
} from '../src/server';
import { TUNNEL_COMMANDS, canDispatchOverTunnel } from '../src/server';
/**
* Tests for the factory-export API surface added so gbrowser (phoenix) can
* consume gstack as a submodule. The full buildFetchHandler hybrid hoist is
* deferred to a follow-up PR; this test file proves the type contract,
* resolveConfigFromEnv behavior, and preserved exports.
*/
describe('server.ts factory API surface', () => {
describe('resolveConfigFromEnv', () => {
test('honors AUTH_TOKEN env var', () => {
const orig = process.env.AUTH_TOKEN;
process.env.AUTH_TOKEN = 'fixed-test-token-abc123';
try {
const cfg = resolveConfigFromEnv();
expect(cfg.authToken).toBe('fixed-test-token-abc123');
} finally {
if (orig === undefined) delete process.env.AUTH_TOKEN;
else process.env.AUTH_TOKEN = orig;
}
});
test('falls back to randomUUID when AUTH_TOKEN env is empty', () => {
const orig = process.env.AUTH_TOKEN;
process.env.AUTH_TOKEN = '';
try {
const cfg = resolveConfigFromEnv();
// randomUUID returns a 36-char hex+dash string.
expect(cfg.authToken).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
} finally {
if (orig === undefined) delete process.env.AUTH_TOKEN;
else process.env.AUTH_TOKEN = orig;
}
});
test('falls back to randomUUID when AUTH_TOKEN is whitespace-only', () => {
const orig = process.env.AUTH_TOKEN;
process.env.AUTH_TOKEN = ' \t \n ';
try {
const cfg = resolveConfigFromEnv();
expect(cfg.authToken).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
expect(cfg.authToken.length).toBe(36);
} finally {
if (orig === undefined) delete process.env.AUTH_TOKEN;
else process.env.AUTH_TOKEN = orig;
}
});
test('AUTH_TOKEN whitespace is stripped (including unicode whitespace)', () => {
const orig = process.env.AUTH_TOKEN;
// 22 chars after stripping leading/trailing whitespace including BOM (U+FEFF)
// and zero-width space (U+200B), so passes the 16-char minimum.
process.env.AUTH_TOKEN = ' padded-token-abc123xyz ';
try {
const cfg = resolveConfigFromEnv();
expect(cfg.authToken).toBe('padded-token-abc123xyz');
} finally {
if (orig === undefined) delete process.env.AUTH_TOKEN;
else process.env.AUTH_TOKEN = orig;
}
});
test('AUTH_TOKEN shorter than 16 chars after stripping falls back to randomUUID', () => {
const orig = process.env.AUTH_TOKEN;
// Only 5 chars of content — too short for the 16-char minimum.
process.env.AUTH_TOKEN = 'short';
try {
const cfg = resolveConfigFromEnv();
// Must be a UUID, not the rejected short token.
expect(cfg.authToken).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
} finally {
if (orig === undefined) delete process.env.AUTH_TOKEN;
else process.env.AUTH_TOKEN = orig;
}
});
test('AUTH_TOKEN of only zero-width unicode whitespace falls back to randomUUID', () => {
const orig = process.env.AUTH_TOKEN;
// U+200B (ZWSP), U+FEFF (BOM), U+00A0 (NBSP) — would pass .trim() but not the unicode-aware strip.
process.env.AUTH_TOKEN = ' ';
try {
const cfg = resolveConfigFromEnv();
expect(cfg.authToken).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
} finally {
if (orig === undefined) delete process.env.AUTH_TOKEN;
else process.env.AUTH_TOKEN = orig;
}
});
test('reads BROWSE_PORT from env, defaults to 0', () => {
const orig = process.env.BROWSE_PORT;
process.env.BROWSE_PORT = '34567';
try {
expect(resolveConfigFromEnv().browsePort).toBe(34567);
} finally {
if (orig === undefined) delete process.env.BROWSE_PORT;
else process.env.BROWSE_PORT = orig;
}
const origUnset = process.env.BROWSE_PORT;
delete process.env.BROWSE_PORT;
try {
expect(resolveConfigFromEnv().browsePort).toBe(0);
} finally {
if (origUnset !== undefined) process.env.BROWSE_PORT = origUnset;
}
});
test('reads BROWSE_IDLE_TIMEOUT from env, defaults to 30 min (1800000ms)', () => {
const orig = process.env.BROWSE_IDLE_TIMEOUT;
delete process.env.BROWSE_IDLE_TIMEOUT;
try {
expect(resolveConfigFromEnv().idleTimeoutMs).toBe(1800000);
} finally {
if (orig !== undefined) process.env.BROWSE_IDLE_TIMEOUT = orig;
}
});
test('returns a populated config object with the expected shape', () => {
const cfg = resolveConfigFromEnv();
expect(cfg).toMatchObject({
authToken: expect.any(String),
browsePort: expect.any(Number),
idleTimeoutMs: expect.any(Number),
config: expect.objectContaining({
stateDir: expect.any(String),
stateFile: expect.any(String),
auditLog: expect.any(String),
}),
});
});
});
describe('preserved exports', () => {
test('TUNNEL_COMMANDS still exported and populated', () => {
expect(TUNNEL_COMMANDS).toBeInstanceOf(Set);
expect(TUNNEL_COMMANDS.size).toBeGreaterThan(0);
expect(TUNNEL_COMMANDS.has('goto')).toBe(true);
expect(TUNNEL_COMMANDS.has('click')).toBe(true);
});
test('canDispatchOverTunnel still exported and functional', () => {
expect(canDispatchOverTunnel('goto')).toBe(true);
expect(canDispatchOverTunnel('shutdown')).toBe(false);
expect(canDispatchOverTunnel(null)).toBe(false);
expect(canDispatchOverTunnel(undefined)).toBe(false);
expect(canDispatchOverTunnel('')).toBe(false);
});
});
describe('type surface compiles', () => {
// Compile-time shape checks. If these break, TypeScript fails to build
// the test file — which is exactly the API-compat guarantee we want for
// embedders depending on these types.
test('Surface type accepts the two known values', () => {
const local: Surface = 'local';
const tunnel: Surface = 'tunnel';
expect(local).toBe('local');
expect(tunnel).toBe('tunnel');
});
test('ServerConfig type accepts the documented minimum-required fields', () => {
// This compiles only if ServerConfig accepts these field names + types.
const minimalConfigShape = {
authToken: 'tok',
browsePort: 0,
idleTimeoutMs: 1800000,
config: { stateDir: '', stateFile: '', consoleLog: '', networkLog: '', dialogLog: '', auditLog: '', projectDir: '' },
browserManager: {} as any,
startTime: Date.now(),
} satisfies Partial<ServerConfig>;
expect(minimalConfigShape.authToken).toBe('tok');
});
test('ServerHandle type exposes the documented surface', () => {
// Compiles only if these property names exist on ServerHandle.
type AssertHandleFields = ServerHandle extends {
fetchLocal: any;
fetchTunnel: any;
shutdown: any;
stopListeners: any;
} ? true : false;
const assertion: AssertHandleFields = true;
expect(assertion).toBe(true);
});
});
});