mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-07 05:56:41 +02:00
fix(security): IPv6 ULA blocking, cookie redaction, per-tab cancel, targeted token (#664)
Community PR #664 by @mr-k-man (security audit round 1, new parts only). - IPv6 ULA prefix blocking (fc00::/7) in url-validation.ts with false-positive guard for hostnames like fd.example.com - Cookie value redaction for tokens, API keys, JWTs in browse cookies command - Per-tab cancel files in killAgent() replacing broken global kill-signal - design/serve.ts: realpathSync upgrade prevents symlink bypass in /api/reload - extension: targeted getToken handler replaces token-in-health-broadcast - Supabase migration 003: column-level GRANT restricts anon UPDATE scope - Telemetry sync: upsert error logging - 10 new tests for IPv6, cookie redaction, DNS rebinding, path traversal Co-Authored-By: mr-k-man <mr-k-man@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { validateOutputPath } from '../src/meta-commands';
|
||||
import { validateReadPath } from '../src/read-commands';
|
||||
import { readFileSync, symlinkSync, unlinkSync, writeFileSync } from 'fs';
|
||||
import { validateReadPath, SENSITIVE_COOKIE_NAME, SENSITIVE_COOKIE_VALUE } from '../src/read-commands';
|
||||
import { BLOCKED_METADATA_HOSTS } from '../src/url-validation';
|
||||
import { readFileSync, symlinkSync, unlinkSync, writeFileSync, realpathSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
@@ -109,3 +110,85 @@ describe('validateReadPath', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateOutputPath — symlink resolution', () => {
|
||||
it('blocks symlink inside /tmp pointing outside safe dirs', () => {
|
||||
const linkPath = join(tmpdir(), 'test-output-symlink-' + Date.now() + '.png');
|
||||
try {
|
||||
symlinkSync('/etc/crontab', linkPath);
|
||||
expect(() => validateOutputPath(linkPath)).toThrow(/Path must be within/);
|
||||
} finally {
|
||||
try { unlinkSync(linkPath); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
it('allows symlink inside /tmp pointing to another /tmp path', () => {
|
||||
// Use /tmp (TEMP_DIR on macOS/Linux), not os.tmpdir() which may be a different path
|
||||
const realTmp = realpathSync('/tmp');
|
||||
const targetPath = join(realTmp, 'test-output-real-' + Date.now() + '.png');
|
||||
const linkPath = join(realTmp, 'test-output-link-' + Date.now() + '.png');
|
||||
try {
|
||||
writeFileSync(targetPath, '');
|
||||
symlinkSync(targetPath, linkPath);
|
||||
expect(() => validateOutputPath(linkPath)).not.toThrow();
|
||||
} finally {
|
||||
try { unlinkSync(linkPath); } catch {}
|
||||
try { unlinkSync(targetPath); } catch {}
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks new file in symlinked directory pointing outside', () => {
|
||||
const linkDir = join(tmpdir(), 'test-dirlink-' + Date.now());
|
||||
try {
|
||||
symlinkSync('/etc', linkDir);
|
||||
expect(() => validateOutputPath(join(linkDir, 'evil.png'))).toThrow(/Path must be within/);
|
||||
} finally {
|
||||
try { unlinkSync(linkDir); } catch {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('cookie redaction — production patterns', () => {
|
||||
it('detects sensitive cookie names', () => {
|
||||
expect(SENSITIVE_COOKIE_NAME.test('session_id')).toBe(true);
|
||||
expect(SENSITIVE_COOKIE_NAME.test('auth_token')).toBe(true);
|
||||
expect(SENSITIVE_COOKIE_NAME.test('csrf-token')).toBe(true);
|
||||
expect(SENSITIVE_COOKIE_NAME.test('api_key')).toBe(true);
|
||||
expect(SENSITIVE_COOKIE_NAME.test('jwt.payload')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores non-sensitive cookie names', () => {
|
||||
expect(SENSITIVE_COOKIE_NAME.test('theme')).toBe(false);
|
||||
expect(SENSITIVE_COOKIE_NAME.test('locale')).toBe(false);
|
||||
expect(SENSITIVE_COOKIE_NAME.test('_ga')).toBe(false);
|
||||
});
|
||||
|
||||
it('detects sensitive cookie value prefixes', () => {
|
||||
expect(SENSITIVE_COOKIE_VALUE.test('eyJhbGciOiJIUzI1NiJ9')).toBe(true); // JWT
|
||||
expect(SENSITIVE_COOKIE_VALUE.test('sk-ant-abc123')).toBe(true); // Anthropic
|
||||
expect(SENSITIVE_COOKIE_VALUE.test('ghp_xxxxxxxxxxxx')).toBe(true); // GitHub PAT
|
||||
expect(SENSITIVE_COOKIE_VALUE.test('xoxb-token')).toBe(true); // Slack
|
||||
});
|
||||
|
||||
it('ignores non-sensitive values', () => {
|
||||
expect(SENSITIVE_COOKIE_VALUE.test('dark')).toBe(false);
|
||||
expect(SENSITIVE_COOKIE_VALUE.test('en-US')).toBe(false);
|
||||
expect(SENSITIVE_COOKIE_VALUE.test('1234567890')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DNS rebinding — production blocklist', () => {
|
||||
it('blocks fd00:: IPv6 metadata address via validateNavigationUrl', async () => {
|
||||
const { validateNavigationUrl } = await import('../src/url-validation');
|
||||
await expect(validateNavigationUrl('http://[fd00::]/')).rejects.toThrow(/cloud metadata/i);
|
||||
});
|
||||
|
||||
it('blocks AWS/GCP IPv4 metadata address', () => {
|
||||
expect(BLOCKED_METADATA_HOSTS.has('169.254.169.254')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not block normal addresses', () => {
|
||||
expect(BLOCKED_METADATA_HOSTS.has('8.8.8.8')).toBe(false);
|
||||
expect(BLOCKED_METADATA_HOSTS.has('2001:4860:4860::8888')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,11 +62,53 @@ describe('validateNavigationUrl', () => {
|
||||
await expect(validateNavigationUrl('http://0251.0376.0251.0376/')).rejects.toThrow(/cloud metadata/i);
|
||||
});
|
||||
|
||||
it('blocks IPv6 metadata with brackets', async () => {
|
||||
it('blocks IPv6 metadata with brackets (fd00::)', async () => {
|
||||
await expect(validateNavigationUrl('http://[fd00::]/')).rejects.toThrow(/cloud metadata/i);
|
||||
});
|
||||
|
||||
it('blocks IPv6 ULA fd00::1 (not just fd00::)', async () => {
|
||||
await expect(validateNavigationUrl('http://[fd00::1]/')).rejects.toThrow(/cloud metadata/i);
|
||||
});
|
||||
|
||||
it('blocks IPv6 ULA fd12:3456::1', async () => {
|
||||
await expect(validateNavigationUrl('http://[fd12:3456::1]/')).rejects.toThrow(/cloud metadata/i);
|
||||
});
|
||||
|
||||
it('blocks IPv6 ULA fc00:: (full fc00::/7 range)', async () => {
|
||||
await expect(validateNavigationUrl('http://[fc00::]/')).rejects.toThrow(/cloud metadata/i);
|
||||
});
|
||||
|
||||
it('does not block hostnames starting with fd (e.g. fd.example.com)', async () => {
|
||||
await expect(validateNavigationUrl('https://fd.example.com/')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not block hostnames starting with fc (e.g. fcustomer.com)', async () => {
|
||||
await expect(validateNavigationUrl('https://fcustomer.com/')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws on malformed URLs', async () => {
|
||||
await expect(validateNavigationUrl('not-a-url')).rejects.toThrow(/Invalid URL/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateNavigationUrl — restoreState coverage', () => {
|
||||
it('blocks file:// URLs that could appear in saved state', async () => {
|
||||
await expect(validateNavigationUrl('file:///etc/passwd')).rejects.toThrow(/scheme.*not allowed/i);
|
||||
});
|
||||
|
||||
it('blocks chrome:// URLs that could appear in saved state', async () => {
|
||||
await expect(validateNavigationUrl('chrome://settings')).rejects.toThrow(/scheme.*not allowed/i);
|
||||
});
|
||||
|
||||
it('blocks metadata IPs that could be injected into state files', async () => {
|
||||
await expect(validateNavigationUrl('http://169.254.169.254/latest/meta-data/')).rejects.toThrow(/cloud metadata/i);
|
||||
});
|
||||
|
||||
it('allows normal https URLs from saved state', async () => {
|
||||
await expect(validateNavigationUrl('https://example.com/page')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows localhost URLs from saved state', async () => {
|
||||
await expect(validateNavigationUrl('http://localhost:3000/app')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user