fix: harden SSRF validation in git URL checker

This commit is contained in:
OpenClaw 2026-04-07 17:40:24 +05:30
parent 4a1f912aee
commit f426a83ece

View file

@ -9,6 +9,7 @@ import { spawn } from 'child_process';
import path from 'path';
import os from 'os';
import fs from 'fs/promises';
import { isIP } from 'net';
/** Extract the repository name from a git URL (HTTPS or SSH). */
export function extractRepoName(url: string): string {
@ -22,9 +23,17 @@ export function getCloneDir(repoName: string): string {
return path.join(os.homedir(), '.gitnexus', 'repos', repoName);
}
// Cloud metadata hostnames that must never be reachable via user-supplied URLs
const BLOCKED_HOSTNAMES = new Set([
'metadata.google.internal',
'metadata.azure.com',
'metadata.internal',
]);
/**
* Validate a git URL to prevent SSRF attacks.
* Only allows https:// and http:// schemes. Blocks private/internal addresses.
* Only allows https:// and http:// schemes. Blocks private/internal addresses,
* IPv6 private ranges, cloud metadata hostnames, and numeric IP encodings.
*/
export function validateGitUrl(url: string): void {
let parsed: URL;
@ -39,18 +48,50 @@ export function validateGitUrl(url: string): void {
}
const host = parsed.hostname.toLowerCase();
// Block well-known internal hostnames
if (host === 'localhost' || BLOCKED_HOSTNAMES.has(host)) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// IPv6 loopback — URL parser strips brackets, so hostname is "::1" not "[::1]"
if (host === '::1') {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// IPv6 private ranges: ULA (fc00::/7), link-local (fe80::), IPv4-mapped (::ffff:)
if (
host === 'localhost' ||
host === '[::1]' ||
/^127\./.test(host) ||
/^10\./.test(host) ||
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
/^192\.168\./.test(host) ||
/^169\.254\./.test(host) ||
/^0\./.test(host)
host.startsWith('fc') ||
host.startsWith('fd') ||
host.startsWith('fe80') ||
host.startsWith('::ffff:')
) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
// IPv4 validation — use net.isIP() to catch decimal/hex encoding bypasses
// (e.g. 2130706433, 0x7f000001 both resolve to 127.0.0.1)
if (isIP(host) === 4) {
const octets = host.split('.').map(Number);
const [a, b] = octets;
if (
a === 127 || // 127.0.0.0/8 loopback
a === 10 || // 10.0.0.0/8 private
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 private
(a === 192 && b === 168) || // 192.168.0.0/16 private
(a === 169 && b === 254) || // 169.254.0.0/16 link-local
a === 0 || // 0.0.0.0/8
(a === 100 && b >= 64 && b <= 127) || // 100.64.0.0/10 CGN (RFC 6598)
(a === 198 && (b === 18 || b === 19)) // 198.18.0.0/15 benchmarking
) {
throw new Error('Cloning from private/internal addresses is not allowed');
}
}
// Reject bare numeric IPs that aren't valid dotted-quad — could be decimal/hex encoding
if (/^\d+$/.test(host) || /^0x[0-9a-f]+$/i.test(host)) {
throw new Error('Numeric IP encoding is not allowed');
}
}
export interface CloneProgress {
@ -91,6 +132,13 @@ function runGit(args: string[], cwd?: string): Promise<void> {
const proc = spawn('git', args, {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
// Prevent git from prompting for credentials (hangs the process)
GIT_TERMINAL_PROMPT: '0',
// Ensure no credential helper tries to open a GUI prompt
GIT_ASKPASS: '/bin/true',
},
});
let stderr = '';