SPB Git

Repository Public

unit.test.ts 13.5 KB 247 lines typescript

1import { describe, expect, it } from 'vitest';2import { seal, unseal, safeEqual } from './crypto.js';3import { hashPassword, hasArgon2, needsRehash, verifyPassword } from './password.js';4import { base32Decode, base32Encode, hotp, otpauthUrl, totpAt, totpCounter, verifyTotp } from './totp.js';5import fs from 'node:fs';6import os from 'node:os';7import path from 'node:path';8import { resolveInRoot, slugify, validateProjectRel, validateRel } from '../paths.js';9import { SecretScrubber, loadConfig } from '../config.js';10import { buildCsp, inlineScriptHashes } from '../static.js';11import { contentDisposition } from '../modules/missions/index.js';12import { STEP_BODY_MAX_CHARS, truncateBody } from '../modules/missions/truncate.js';13import { sanitizeRemote, validateGitUrl } from '../modules/projects/git.js';1415const cost = { argonMemoryKiB: 1024, argonPasses: 2, scryptLogN: 10 };1617describe('TOTP (RFC 6238 / RFC 4226)', () => {18  const secret = Buffer.from('12345678901234567890', 'ascii');19  // RFC 6238 Appendix B, SHA-1 rows (8 digits there; the low 6 digits are the 6-digit code).20  const vectors: [number, string][] = [21    [59, '94287082'], [1111111109, '07081804'], [1111111111, '14050471'],22    [1234567890, '89005924'], [2000000000, '69279037'], [20000000000, '65353130'],23  ];24  it.each(vectors)('t=%d s', (t, code8) => {25    expect(totpAt(secret, t * 1000, 8)).toBe(code8);26    expect(totpAt(secret, t * 1000)).toBe(code8.slice(2));27  });28  it('RFC 4226 HOTP vectors', () => {29    const expected = ['755224', '287082', '359152', '969429', '338314', '254676', '287922', '162583', '399871', '520489'];30    expected.forEach((code, i) => expect(hotp(secret, i)).toBe(code));31  });32  it('accepts ±1 step, rejects ±2, and refuses replays', () => {33    const now = 1_700_000_000_000;34    const c = totpCounter(now);35    expect(verifyTotp(secret, hotp(secret, c), now, null)).toBe(c);36    expect(verifyTotp(secret, hotp(secret, c - 1), now, null)).toBe(c - 1);37    expect(verifyTotp(secret, hotp(secret, c + 1), now, null)).toBe(c + 1);38    expect(verifyTotp(secret, hotp(secret, c - 2), now, null)).toBeNull();39    expect(verifyTotp(secret, hotp(secret, c + 2), now, null)).toBeNull();40    expect(verifyTotp(secret, hotp(secret, c), now, c)).toBeNull(); // same code again41    expect(verifyTotp(secret, hotp(secret, c - 1), now, c)).toBeNull(); // older code after a newer one42    expect(verifyTotp(secret, 'abcdef', now, null)).toBeNull();43  });44  it('base32 round-trips and matches RFC 4648', () => {45    expect(base32Encode(Buffer.from('foobar'))).toBe('MZXW6YTBOI');46    expect(base32Decode('MZXW6YTBOI').toString()).toBe('foobar');47    expect(base32Encode(secret)).toBe('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ');48    expect(otpauthUrl('ABC', 'admin')).toMatch(/^otpauth:\/\/totp\/Agent%20OS:admin\?secret=ABC&issuer=Agent\+OS/);49  });50});5152describe('password hashing', () => {53  it('hashes, verifies and reports parameters', async () => {54    const h = await hashPassword('a long enough password', cost);55    expect(h.startsWith(hasArgon2 ? '$argon2id$v=19$m=1024,t=2,p=1$' : '$scrypt$ln=10,r=8,p=1$')).toBe(true);56    expect(await verifyPassword('a long enough password', h)).toBe(true);57    expect(await verifyPassword('a long enough passwore', h)).toBe(false);58    expect(needsRehash(h, cost)).toBe(false);59    expect(needsRehash(h, { ...cost, argonMemoryKiB: 2048, scryptLogN: 11 })).toBe(true);60  });61  it('scrypt fallback verifies and malformed hashes never throw', async () => {62    const h = await hashPassword('another password 123', cost, true);63    expect(h.startsWith('$scrypt$ln=10,r=8,p=1$')).toBe(true);64    expect(await verifyPassword('another password 123', h)).toBe(true);65    expect(await verifyPassword('nope', h)).toBe(false);66    for (const bad of ['', 'plain', '$argon2id$v=19$m=x$a$b', '$scrypt$ln=99,r=8,p=1$aa$bb', '$md5$x$y$z']) {67      expect(await verifyPassword('x', bad)).toBe(false);68    }69  });70});7172describe('crypto helpers', () => {73  const key = Buffer.alloc(32, 7);74  it('seals with AES-256-GCM bound to its AAD', () => {75    const sealed = seal(key, 'JBSWY3DPEHPK3PXP', 'totp:u1');76    expect(sealed).not.toContain('JBSWY3DPEHPK3PXP');77    expect(unseal(key, sealed, 'totp:u1')).toBe('JBSWY3DPEHPK3PXP');78    expect(() => unseal(key, sealed, 'totp:u2')).toThrow();79    expect(() => unseal(Buffer.alloc(32, 8), sealed, 'totp:u1')).toThrow();80    const parts = sealed.split('.');81    parts[3] = `${parts[3]?.slice(0, -2)}AA`;82    expect(() => unseal(key, parts.join('.'), 'totp:u1')).toThrow();83  });84  it('safeEqual', () => {85    expect(safeEqual('abc', 'abc')).toBe(true);86    expect(safeEqual('abc', 'abd')).toBe(false);87    expect(safeEqual('abc', 'abcd')).toBe(false);88  });89});9091describe('path validation', () => {92  it.each(['../x', 'a/../../b', '/etc/passwd', 'a\0b', '..', 'a/..', '~/x', '\\\\srv\\x', 'C:\\x'])('rejects %j', (p) => {93    expect(() => validateRel(p)).toThrow(/invalid path/);94  });95  it('normalises clean paths', () => {96    expect(validateRel('a//b/./c/')).toBe('a/b/c');97    expect(validateRel('')).toBe('');98    expect(validateRel('..a/b..')).toBe('..a/b..');99    expect(() => validateRel('', { allowEmpty: false })).toThrow();100  });101});102103describe('configuration', () => {104  const prod = {105    NODE_ENV: 'production', AOS_PUBLIC_ORIGIN: 'https://agent.example.org', DATABASE_URL: 'postgres://x/y',106    AOS_SECRET_KEY: 'ab'.repeat(32), AOS_DATA_DIR: '/tmp/aos-none', AOS_WORKSPACE_ROOT: '/tmp/ws',107  };108  it('accepts a complete production configuration with secure cookies', () => {109    const c = loadConfig(prod);110    expect(c.cookieName).toBe('__Host-aos_sid');111    expect(c.secureCookies).toBe(true);112    expect(c.rpId).toBe('agent.example.org');113    expect(c.trustedProxies).toEqual(['127.0.0.1', '::1', '10.67.0.1']);114    expect(c.preAuthCookieName).toBe('__Host-aos_pre');115    expect(c.port).toBe(8640);116    expect(c.workspaceRoot).toBe('/tmp/ws');117  });118  it('model API keys are known as booleans only; their values never serialise', () => {119    const c = loadConfig({ ...prod, ANTHROPIC_API_KEY: 'sk-ant-SECRETVALUE-123456' });120    expect(c.providerKeys).toEqual({ anthropic: true, openai: false });121    expect(JSON.stringify(c)).not.toContain('SECRETVALUE');122    expect(c.secrets.scrub('x sk-ant-SECRETVALUE-123456 y sk-ant-SECRETVALUE-123456')).toBe('x [redacted] y [redacted]');123    expect(new SecretScrubber([undefined, '', 'short']).active).toBe(false);124  });125  it.each([126    ['AOS_SECRET_KEY', ''], ['AOS_SECRET_KEY', 'abcd'], ['DATABASE_URL', ''],127    ['AOS_PUBLIC_ORIGIN', 'http://agent.example.org'], ['AOS_PUBLIC_ORIGIN', 'https://agent.example.org/path'],128    ['AOS_TRUSTED_PROXIES', 'not-an-ip'], ['AOS_TRUSTED_PROXIES', '10.0.0.0/99'],129  ])('refuses production with %s=%j', (k, v) => {130    expect(() => loadConfig({ ...prod, [k]: v })).toThrow(/Invalid configuration/);131  });132  it('never downgrades cookies in production, and only for http://localhost otherwise', () => {133    expect(loadConfig({ ...prod, NODE_ENV: 'development', AOS_PUBLIC_ORIGIN: 'http://localhost:8640' }).cookieName).toBe('aos_sid');134    expect(loadConfig({ ...prod, NODE_ENV: 'development', AOS_PUBLIC_ORIGIN: 'http://example.org' }).cookieName).toBe('__Host-aos_sid');135    expect(() => loadConfig({ ...prod, AOS_PUBLIC_ORIGIN: 'http://localhost:8640' })).toThrow();136  });137});138139describe('CSP', () => {140  const c = loadConfig({ NODE_ENV: 'test', AOS_PUBLIC_ORIGIN: 'https://agent.example.org', AOS_SECRET_KEY: 'ab'.repeat(32), AOS_DATA_DIR: '/tmp/aos-none', AOS_WORKSPACE_ROOT: '/tmp/ws' });141  it('hashes inline scripts only', () => {142    const html = '<script src="/a.js"></script><script>alert(1)</script><script type="application/json">{"a":1}</script><script id="x">self.x=1</script>';143    const hashes = inlineScriptHashes(html);144    expect(hashes).toHaveLength(2);145    expect(hashes[0]).toBe("'sha256-bhHHL3z2vDgxUt0W3dWQOrprscmda2Y5pLsLg4GF+pI='"); // sha256("alert(1)")146  });147  it('never allows unsafe-inline scripts', () => {148    const csp = buildCsp(c, ["'sha256-abc='"]);149    const script = csp.split('; ').find((d) => d.startsWith('script-src')) ?? '';150    expect(script).toBe("script-src 'self' 'wasm-unsafe-eval' 'sha256-abc='");151    expect(script).not.toContain('unsafe-inline');152    expect(csp).toContain("frame-ancestors 'none'");153    expect(csp).toContain("connect-src 'self' wss://agent.example.org");154    expect(csp).toContain("object-src 'none'");155    // Same-origin PDF preview only: no third-party frame source, plugins stay forbidden.156    expect(csp.split('; ').find((d) => d.startsWith('frame-src'))).toBe("frame-src 'self' blob:");157    expect(csp.split('; ').filter((d) => d.startsWith('frame-') || d.startsWith('object-'))).toEqual(["frame-src 'self' blob:", "frame-ancestors 'none'", "object-src 'none'"]);158  });159});160161describe('project path safety', () => {162  it.each(['', '.', 'a/.git', '.git', 'x/.aos/y', '-rf', 'a/-b', 'a\\b', 'a\nb', '../x', '/abs'])('refuses project path %j', (p) => {163    expect(() => validateProjectRel(p)).toThrow(/invalid path/);164  });165  it('resolves inside the root and refuses symlink escapes', () => {166    const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'aos-paths-')));167    const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'aos-out-')));168    try {169      fs.mkdirSync(path.join(root, 'a', 'b'), { recursive: true });170      fs.symlinkSync(outside, path.join(root, 'escape'));171      fs.symlinkSync(path.join(root, 'a'), path.join(root, 'inner'));172      fs.symlinkSync(path.join(root, 'nowhere'), path.join(root, 'dangling'));173      expect(resolveInRoot(root, 'a/b')).toEqual({ abs: path.join(root, 'a', 'b'), exists: true });174      expect(resolveInRoot(root, 'a/new/deep')).toEqual({ abs: path.join(root, 'a', 'new', 'deep'), exists: false });175      expect(resolveInRoot(root, 'inner/b')).toEqual({ abs: path.join(root, 'a', 'b'), exists: true }); // symlink inside the root: fine, realpath176      for (const p of ['escape', 'escape/x', 'dangling', 'dangling/x']) expect(() => resolveInRoot(root, p), p).toThrow(/escapes the workspace root/);177      expect(() => resolveInRoot(path.join(root, 'missing-root'), 'a')).toThrow(/workspace root does not exist/);178    } finally {179      fs.rmSync(root, { recursive: true, force: true });180      fs.rmSync(outside, { recursive: true, force: true });181    }182  });183  it('slugify never yields an option-like or empty name', () => {184    expect(slugify('My Project!')).toBe('my-project');185    expect(slugify('--force')).toBe('force');186    expect(slugify('…')).toBe('project');187  });188});189190describe('git URL whitelist', () => {191  it.each([192    'https://github.com/org/repo.git', 'https://user:tok@git.example.org/x/y', 'ssh://git@host.example:2222/org/repo.git',193    'git@github.com:org/repo.git', 'gitsrv:srv/git/app.git',194  ])('accepts %s', (u) => {195    expect(validateGitUrl(u)).toBe(u);196  });197  it.each([198    '', 'http://github.com/org/repo', 'file:///etc/passwd', '/etc/passwd', '../repo', 'ext::sh -c id', '--upload-pack=touch /tmp/x',199    '-oProxyCommand=id', 'https://host/a b', 'https://host/a\nb', 'git@host:../../x', 'ssh://-oProxyCommand=id/x', 'gitsrv:a;id', 'https://h/$(id)',200    'git://host/repo', 'ftp://host/repo', 'https://host/x`id`',201  ])('refuses %j', (u) => {202    expect(() => validateGitUrl(u)).toThrow(/git URL not allowed/);203  });204  it('credentials are stripped from remotes before they are stored or shown', () => {205    expect(sanitizeRemote('https://user:ghp_SECRET@github.com/org/repo.git')).toBe('https://github.com/org/repo.git');206    expect(sanitizeRemote('git@github.com:org/repo.git')).toBe('git@github.com:org/repo.git');207    expect(sanitizeRemote(null)).toBeNull();208  });209});210211describe('step body truncation', () => {212  it('leaves small bodies untouched', () => {213    const v = { name: 'read_file', input: { path: 'a.txt', lines: [1, 2] }, n: null };214    expect(truncateBody(v)).toEqual({ value: v, truncated: false });215    expect(truncateBody(null)).toEqual({ value: null, truncated: false });216  });217  it('bounds the body, keeps the structure and flags it', () => {218    const big = { name: 'write_file', input: { path: 'a.txt', content: 'x'.repeat(100_000) }, after: 'kept-or-dropped', list: Array.from({ length: 5000 }, (_, i) => `item-${i}`) };219    const out = truncateBody(big);220    expect(out.truncated).toBe(true);221    const json = JSON.stringify(out.value);222    expect(json.length).toBeLessThan(STEP_BODY_MAX_CHARS + 500);223    const v = out.value as { name: string; input: { path: string; content: string } };224    expect(v.name).toBe('write_file');225    expect(v.input.path).toBe('a.txt');226    expect(v.input.content).toMatch(/^x{19000,20000}…\[truncated \d+ chars\]$/);227  });228  it('a huge array of small items and a deep nesting are bounded too', () => {229    const arr = truncateBody(Array.from({ length: 200_000 }, (_, i) => i));230    expect(arr.truncated).toBe(true);231    expect(JSON.stringify(arr.value).length).toBeLessThan(40_000);232    let deep: unknown = 'leaf';233    for (let i = 0; i < 500; i++) deep = { d: deep };234    expect(truncateBody(deep).truncated).toBe(true);235  });236});237238describe('artifact download names', () => {239  it('are attachments with a safe ASCII fallback and an RFC 5987 form', () => {240    expect(contentDisposition('report.txt')).toBe(`attachment; filename="report.txt"; filename*=UTF-8''report.txt`);241    expect(contentDisposition('../../etc/pass"wd\r\nX: y')).toBe(`attachment; filename="pass_wdX__y"; filename*=UTF-8''pass%22wdX%3A%20y`);242    expect(contentDisposition('résumé.html')).toMatch(/^attachment; filename="r_sum_.html"; filename\*=UTF-8''r%C3%A9sum%C3%A9\.html$/);243    expect(contentDisposition('.htaccess')).toContain('filename="_htaccess"');244    expect(contentDisposition('')).toContain('filename="artifact"');245  });246});247
Files main
Menu

Theme