719032b19f
Update Schema / Update configuration json schema (push) Has been cancelled
Memory Benchmark / Memory Test (Full Analysis) (push) Has been cancelled
CI Quality / Lint GitHub Actions (actionlint) (push) Has been cancelled
CI Quality / Lint GitHub Actions (zizmor) (push) Has been cancelled
CI Quality / Check typos (push) Has been cancelled
CI / Test coverage (push) Has been cancelled
CI / Test (22.x, windows-latest) (push) Has been cancelled
CI / Test (24.x, macos-latest) (push) Has been cancelled
CI / Test (24.x, ubuntu-latest) (push) Has been cancelled
CI / Test (24.x, windows-latest) (push) Has been cancelled
CI / Test (26.x, macos-latest) (push) Has been cancelled
CI / Test (26.x, ubuntu-latest) (push) Has been cancelled
CI / Test (26.x, windows-latest) (push) Has been cancelled
CI / Test with Bun (latest, macos-latest) (push) Has been cancelled
CI / Test with Bun (latest, ubuntu-latest) (push) Has been cancelled
CI / Test with Bun (latest, windows-latest) (push) Has been cancelled
CI / Build and run (22.x, macos-latest) (push) Has been cancelled
CI / Build and run (22.x, ubuntu-latest) (push) Has been cancelled
CI / Build and run (22.x, windows-latest) (push) Has been cancelled
autofix.ci / autofix (push) Has been cancelled
CI Browser Extension / Lint Browser Extension (push) Has been cancelled
CI Browser Extension / Test Browser Extension (push) Has been cancelled
Memory Benchmark / Memory Test (push) Has been cancelled
CI Website / Lint Website Client (push) Has been cancelled
CI Website / Lint Website Server (push) Has been cancelled
CI Website / Test Website Server (push) Has been cancelled
CI Website / Bundle Website Server (push) Has been cancelled
CI / Lint Biome (push) Has been cancelled
CI / Lint oxlint (push) Has been cancelled
CI / Lint TypeScript (push) Has been cancelled
CI / Lint Secretlint (push) Has been cancelled
CI / Test (22.x, macos-latest) (push) Has been cancelled
CI / Test (22.x, ubuntu-latest) (push) Has been cancelled
CI / Build and run (24.x, macos-latest) (push) Has been cancelled
CI / Build and run (24.x, ubuntu-latest) (push) Has been cancelled
CI / Build and run (24.x, windows-latest) (push) Has been cancelled
CI / Build and run (26.x, macos-latest) (push) Has been cancelled
CI / Build and run (26.x, ubuntu-latest) (push) Has been cancelled
CI / Build and run (26.x, windows-latest) (push) Has been cancelled
CI / Build and run with Bun (latest, macos-latest) (push) Has been cancelled
CI / Build and run with Bun (latest, ubuntu-latest) (push) Has been cancelled
CI / Build and run with Bun (latest, windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Docker / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Docker / build (linux/arm/v7, ubuntu-24.04-arm) (push) Has been cancelled
Docker / build (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker / merge (push) Has been cancelled
Pack repository with Repomix / pack-repo (push) Has been cancelled
Performance Benchmark History / Benchmark (macos-latest) (push) Has been cancelled
Performance Benchmark History / Benchmark (ubuntu-latest) (push) Has been cancelled
Performance Benchmark History / Benchmark (windows-latest) (push) Has been cancelled
Performance Benchmark History / Store Results (push) Has been cancelled
Test Repomix Action / Test Node.js 22 (push) Has been cancelled
Test Repomix Action / Test Node.js 24 (push) Has been cancelled
Test Repomix Action / Test Node.js 26 (push) Has been cancelled
158 lines
6.4 KiB
TypeScript
158 lines
6.4 KiB
TypeScript
import { describe, expect, test, vi } from 'vitest';
|
|
import {
|
|
assertPublicHttpsRepoUrl,
|
|
isBlockedIpAddress,
|
|
type ValidateUrlDeps,
|
|
} from '../src/domains/pack/validateRemoteRepoUrl.js';
|
|
import { AppError } from '../src/utils/errorHandler.js';
|
|
|
|
// A lookup stub that resolves every hostname to a fixed set of addresses.
|
|
const stubLookup = (addresses: Array<{ address: string; family: number }>): ValidateUrlDeps['lookup'] =>
|
|
vi.fn(async () => addresses);
|
|
|
|
// Public IP so the DNS-resolution branch passes for legitimate hosts.
|
|
const publicDeps: ValidateUrlDeps = { lookup: stubLookup([{ address: '140.82.121.3', family: 4 }]) };
|
|
|
|
describe('isBlockedIpAddress', () => {
|
|
test.each([
|
|
'0.0.0.0',
|
|
'10.0.0.1',
|
|
'100.64.0.1',
|
|
'127.0.0.1',
|
|
'169.254.169.254', // GCP/AWS metadata
|
|
'172.16.0.1',
|
|
'172.31.255.255',
|
|
'192.168.1.1',
|
|
'198.18.0.1',
|
|
'255.255.255.255',
|
|
'::1',
|
|
'::',
|
|
'fc00::1',
|
|
'fd12:3456::1',
|
|
'fe80::1',
|
|
'fec0::1', // site-local (deprecated)
|
|
'::ffff:127.0.0.1', // IPv4-mapped loopback (dotted)
|
|
'::ffff:169.254.169.254',
|
|
'::ffff:7f00:1', // IPv4-mapped loopback (hex form new URL normalizes to)
|
|
'::ffff:a9fe:a9fe', // IPv4-mapped metadata (hex form)
|
|
'0:0:0:0:0:0:0:1', // fully expanded loopback
|
|
])('blocks reserved address %s', (ip) => {
|
|
expect(isBlockedIpAddress(ip)).toBe(true);
|
|
});
|
|
|
|
test.each([
|
|
'8.8.8.8',
|
|
'140.82.121.3', // github.com
|
|
'1.1.1.1',
|
|
'2606:4700:4700::1111', // public IPv6 (Cloudflare)
|
|
'::ffff:8.8.8.8', // IPv4-mapped public
|
|
'172.15.0.1', // just below the 172.16/12 private block
|
|
'172.32.0.1', // just above the 172.16/12 private block
|
|
])('allows public address %s', (ip) => {
|
|
expect(isBlockedIpAddress(ip)).toBe(false);
|
|
});
|
|
|
|
test('returns false for non-IP strings', () => {
|
|
expect(isBlockedIpAddress('github.com')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('assertPublicHttpsRepoUrl', () => {
|
|
const expectRejected = async (url: string, deps: ValidateUrlDeps = publicDeps) => {
|
|
await expect(assertPublicHttpsRepoUrl(url, deps)).rejects.toBeInstanceOf(AppError);
|
|
await expect(assertPublicHttpsRepoUrl(url, deps)).rejects.toMatchObject({ statusCode: 400 });
|
|
};
|
|
|
|
test('accepts a public https repository URL', async () => {
|
|
await expect(assertPublicHttpsRepoUrl('https://github.com/owner/repo.git', publicDeps)).resolves.toBeUndefined();
|
|
});
|
|
|
|
test('rejects file:// URLs (LFI, issue #1704)', async () => {
|
|
await expectRejected('file:///etc/passwd');
|
|
await expectRejected('file:///tmp/test-lfi-repo');
|
|
});
|
|
|
|
test('rejects http:// URLs (SSRF, issue #1703)', async () => {
|
|
await expectRejected('http://172.18.0.1:9999/user/repo.git');
|
|
await expectRejected('http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token');
|
|
});
|
|
|
|
test('rejects non-http(s) protocols', async () => {
|
|
await expectRejected('ssh://git@github.com/owner/repo.git');
|
|
await expectRejected('git://github.com/owner/repo.git');
|
|
await expectRejected('ftp://example.com/repo.git');
|
|
});
|
|
|
|
test('rejects SSH scp-like syntax that is not a parseable URL', async () => {
|
|
await expectRejected('git@github.com:owner/repo.git');
|
|
});
|
|
|
|
test('rejects https to loopback / private IP literals', async () => {
|
|
await expectRejected('https://127.0.0.1/owner/repo.git');
|
|
await expectRejected('https://169.254.169.254/latest/meta-data');
|
|
await expectRejected('https://10.0.0.5/owner/repo.git');
|
|
await expectRejected('https://[::1]/owner/repo.git');
|
|
});
|
|
|
|
test('rejects IPv4-mapped IPv6 literals that new URL normalizes to hex', async () => {
|
|
// new URL('https://[::ffff:127.0.0.1]/…') canonicalizes the host to
|
|
// [::ffff:7f00:1]; byte-level matching must still catch the loopback.
|
|
await expectRejected('https://[::ffff:127.0.0.1]/owner/repo.git');
|
|
await expectRejected('https://[::ffff:169.254.169.254]/owner/repo.git');
|
|
});
|
|
|
|
test('rejects blocked hostnames without needing DNS', async () => {
|
|
// lookup that would throw if called — proves these are blocked by name.
|
|
const throwingDeps: ValidateUrlDeps = {
|
|
lookup: vi.fn(async () => {
|
|
throw new Error('lookup should not be called');
|
|
}),
|
|
};
|
|
await expectRejected('https://localhost/owner/repo.git', throwingDeps);
|
|
await expectRejected('https://metadata.google.internal/owner/repo.git', throwingDeps);
|
|
await expectRejected('https://foo.internal/owner/repo.git', throwingDeps);
|
|
});
|
|
|
|
test('rejects a public hostname that resolves to a private address (DNS rebinding / obfuscation)', async () => {
|
|
const rebindDeps: ValidateUrlDeps = { lookup: stubLookup([{ address: '10.0.0.1', family: 4 }]) };
|
|
await expectRejected('https://evil.example.com/owner/repo.git', rebindDeps);
|
|
});
|
|
|
|
test('rejects blocked hostnames case-insensitively and with a trailing FQDN dot', async () => {
|
|
const throwingDeps: ValidateUrlDeps = {
|
|
lookup: vi.fn(async () => {
|
|
throw new Error('lookup should not be called');
|
|
}),
|
|
};
|
|
await expectRejected('https://LOCALHOST/owner/repo.git', throwingDeps);
|
|
// A trailing dot denotes an absolute FQDN and must resolve identically to
|
|
// the bare hostname — `new URL()` preserves it verbatim on `.hostname`.
|
|
await expectRejected('https://localhost./owner/repo.git', throwingDeps);
|
|
await expectRejected('https://METADATA.GOOGLE.INTERNAL/owner/repo.git', throwingDeps);
|
|
});
|
|
|
|
test('does not over-block hostnames that merely contain a blocked label as a substring', async () => {
|
|
// `.internal` / `localhost` matching is suffix-based (a real label
|
|
// boundary), not a substring check — a hostname like
|
|
// `my-internal-service.example.com` must not be blocked just because it
|
|
// contains "internal", since its actual (rightmost) label is `.com`.
|
|
await expect(
|
|
assertPublicHttpsRepoUrl('https://my-internal-service.example.com/owner/repo.git', publicDeps),
|
|
).resolves.toBeUndefined();
|
|
await expect(
|
|
assertPublicHttpsRepoUrl('https://notlocalhost.example.com/owner/repo.git', publicDeps),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
test('does not block when DNS resolution fails (availability over false rejection)', async () => {
|
|
const failingDeps: ValidateUrlDeps = {
|
|
lookup: vi.fn(async () => {
|
|
throw new Error('ENOTFOUND');
|
|
}),
|
|
};
|
|
await expect(
|
|
assertPublicHttpsRepoUrl('https://does-not-resolve.example.com/owner/repo.git', failingDeps),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|