mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-05 05:05:08 +02:00
fix: block SSRF via URL validation in browse commands (#17)
Adds validateNavigationUrl() that blocks non-HTTP(S) schemes (file://, javascript:, data:) and cloud metadata endpoints (169.254.169.254, metadata.google.internal). Applied to goto, diff, and newTab commands. Localhost and private IPs remain allowed for local dev QA. Closes #17 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
|
||||
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright';
|
||||
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
|
||||
import { validateNavigationUrl } from './url-validation';
|
||||
|
||||
export interface RefEntry {
|
||||
locator: Locator;
|
||||
@@ -128,6 +129,7 @@ export class BrowserManager {
|
||||
this.wirePageEvents(page);
|
||||
|
||||
if (url) {
|
||||
validateNavigationUrl(url);
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { BrowserManager } from './browser-manager';
|
||||
import { handleSnapshot } from './snapshot';
|
||||
import { getCleanText } from './read-commands';
|
||||
import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
|
||||
import { validateNavigationUrl } from './url-validation';
|
||||
import * as Diff from 'diff';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
@@ -13,7 +14,7 @@ import * as path from 'path';
|
||||
// Security: Path validation to prevent path traversal attacks
|
||||
const SAFE_DIRECTORIES = ['/tmp', process.cwd()];
|
||||
|
||||
function validateOutputPath(filePath: string): void {
|
||||
export function validateOutputPath(filePath: string): void {
|
||||
const resolved = path.resolve(filePath);
|
||||
const isSafe = SAFE_DIRECTORIES.some(dir => resolved === dir || resolved.startsWith(dir + '/'));
|
||||
if (!isSafe) {
|
||||
@@ -221,9 +222,11 @@ export async function handleMetaCommand(
|
||||
if (!url1 || !url2) throw new Error('Usage: browse diff <url1> <url2>');
|
||||
|
||||
const page = bm.getPage();
|
||||
validateNavigationUrl(url1);
|
||||
await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
const text1 = await getCleanText(page);
|
||||
|
||||
validateNavigationUrl(url2);
|
||||
await page.goto(url2, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
const text2 = await getCleanText(page);
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* URL validation for navigation commands — blocks dangerous schemes and cloud metadata endpoints.
|
||||
* Localhost and private IPs are allowed (primary use case: QA testing local dev servers).
|
||||
*/
|
||||
|
||||
const BLOCKED_METADATA_HOSTS = [
|
||||
'169.254.169.254', // AWS/GCP/Azure instance metadata
|
||||
'fd00::', // IPv6 unique local (metadata in some cloud setups)
|
||||
'metadata.google.internal', // GCP metadata
|
||||
];
|
||||
|
||||
export function validateNavigationUrl(url: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`Invalid URL: ${url}`);
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(
|
||||
`Blocked: scheme "${parsed.protocol}" is not allowed. Only http: and https: URLs are permitted.`
|
||||
);
|
||||
}
|
||||
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
if (BLOCKED_METADATA_HOSTS.includes(hostname)) {
|
||||
throw new Error(
|
||||
`Blocked: ${hostname} is a cloud metadata endpoint. Access is denied for security.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { findInstalledBrowsers, importCookies } from './cookie-import-browser';
|
||||
import { validateNavigationUrl } from './url-validation';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@@ -21,6 +22,7 @@ export async function handleWriteCommand(
|
||||
case 'goto': {
|
||||
const url = args[0];
|
||||
if (!url) throw new Error('Usage: browse goto <url>');
|
||||
validateNavigationUrl(url);
|
||||
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
const status = response?.status() || 'unknown';
|
||||
return `Navigated to ${url} (${status})`;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { validateNavigationUrl } from '../src/url-validation';
|
||||
|
||||
describe('validateNavigationUrl', () => {
|
||||
it('allows http URLs', () => {
|
||||
expect(() => validateNavigationUrl('http://example.com')).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows https URLs', () => {
|
||||
expect(() => validateNavigationUrl('https://example.com/path?q=1')).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows localhost', () => {
|
||||
expect(() => validateNavigationUrl('http://localhost:3000')).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows 127.0.0.1', () => {
|
||||
expect(() => validateNavigationUrl('http://127.0.0.1:8080')).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows private IPs', () => {
|
||||
expect(() => validateNavigationUrl('http://192.168.1.1')).not.toThrow();
|
||||
});
|
||||
|
||||
it('blocks file:// scheme', () => {
|
||||
expect(() => validateNavigationUrl('file:///etc/passwd')).toThrow(/scheme.*not allowed/i);
|
||||
});
|
||||
|
||||
it('blocks javascript: scheme', () => {
|
||||
expect(() => validateNavigationUrl('javascript:alert(1)')).toThrow(/scheme.*not allowed/i);
|
||||
});
|
||||
|
||||
it('blocks data: scheme', () => {
|
||||
expect(() => validateNavigationUrl('data:text/html,<h1>hi</h1>')).toThrow(/scheme.*not allowed/i);
|
||||
});
|
||||
|
||||
it('blocks AWS/GCP metadata endpoint', () => {
|
||||
expect(() => validateNavigationUrl('http://169.254.169.254/latest/meta-data/')).toThrow(/cloud metadata/i);
|
||||
});
|
||||
|
||||
it('blocks GCP metadata hostname', () => {
|
||||
expect(() => validateNavigationUrl('http://metadata.google.internal/computeMetadata/v1/')).toThrow(/cloud metadata/i);
|
||||
});
|
||||
|
||||
it('throws on malformed URLs', () => {
|
||||
expect(() => validateNavigationUrl('not-a-url')).toThrow(/Invalid URL/i);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user