feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { htmlToText } from './html.js';
|
||||
|
||||
describe('htmlToText', () => {
|
||||
it('script と style タグを除去する', () => {
|
||||
const html = '<p>Hello</p><script>alert("x")</script><style>.a{}</style><p>World</p>';
|
||||
const result = htmlToText(html);
|
||||
expect(result).not.toContain('alert');
|
||||
expect(result).not.toContain('.a{}');
|
||||
expect(result).toContain('Hello');
|
||||
expect(result).toContain('World');
|
||||
});
|
||||
|
||||
it('nav, footer, header を除去する', () => {
|
||||
const html = '<nav>Nav</nav><main>Content</main><footer>Footer</footer>';
|
||||
const result = htmlToText(html);
|
||||
expect(result).not.toContain('Nav');
|
||||
expect(result).not.toContain('Footer');
|
||||
expect(result).toContain('Content');
|
||||
});
|
||||
|
||||
it('ブロック要素の前後に改行を入れる', () => {
|
||||
const html = '<p>First</p><p>Second</p>';
|
||||
const result = htmlToText(html);
|
||||
expect(result).toContain('First');
|
||||
expect(result).toContain('Second');
|
||||
expect(result).toMatch(/First\n+Second/);
|
||||
});
|
||||
|
||||
it('HTML エンティティをデコードする', () => {
|
||||
const html = '<p>& < > " ' </p>';
|
||||
const result = htmlToText(html);
|
||||
expect(result).toContain('& < > "');
|
||||
expect(result).toContain("'");
|
||||
});
|
||||
|
||||
it('3行以上の連続空行を2行に正規化する', () => {
|
||||
const html = '<p>A</p><br><br><br><br><br><p>B</p>';
|
||||
const result = htmlToText(html);
|
||||
expect(result).not.toMatch(/\n{3,}/);
|
||||
});
|
||||
|
||||
it('10000文字超で切り捨てる', () => {
|
||||
const html = '<p>' + 'a'.repeat(15000) + '</p>';
|
||||
const result = htmlToText(html);
|
||||
const truncationSuffix = '\n... (truncated)';
|
||||
expect(result.length).toBeLessThanOrEqual(10000 + truncationSuffix.length);
|
||||
expect(result).toContain('... (truncated)');
|
||||
});
|
||||
|
||||
it('空文字列を渡すと空文字列を返す', () => {
|
||||
expect(htmlToText('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
export function htmlToText(html: string): string {
|
||||
let text = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<nav[\s\S]*?<\/nav>/gi, '')
|
||||
.replace(/<footer[\s\S]*?<\/footer>/gi, '')
|
||||
.replace(/<header[\s\S]*?<\/header>/gi, '');
|
||||
|
||||
text = text.replace(/<\/?(p|div|h[1-6]|li|br|tr|blockquote)[^>]*>/gi, '\n');
|
||||
text = text.replace(/<[^>]+>/g, '');
|
||||
|
||||
text = text
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/ /gi, ' ');
|
||||
|
||||
text = text
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\n[ \t]+/g, '\n')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
if (text.length > 10000) {
|
||||
text = text.slice(0, 10000) + '\n... (truncated)';
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isPrivateIPv4, isPrivateIPv6, isHostAllowed, checkSSRF } from './ssrf.js';
|
||||
|
||||
describe('isPrivateIPv4', () => {
|
||||
it('127.x はプライベート', () => {
|
||||
expect(isPrivateIPv4('127.0.0.1')).toBe(true);
|
||||
expect(isPrivateIPv4('127.255.255.255')).toBe(true);
|
||||
});
|
||||
it('10.x はプライベート', () => {
|
||||
expect(isPrivateIPv4('10.0.0.1')).toBe(true);
|
||||
expect(isPrivateIPv4('10.255.255.255')).toBe(true);
|
||||
});
|
||||
it('172.16-31.x はプライベート', () => {
|
||||
expect(isPrivateIPv4('172.16.0.1')).toBe(true);
|
||||
expect(isPrivateIPv4('172.31.255.255')).toBe(true);
|
||||
expect(isPrivateIPv4('172.15.0.1')).toBe(false);
|
||||
expect(isPrivateIPv4('172.32.0.1')).toBe(false);
|
||||
});
|
||||
it('192.168.x はプライベート', () => {
|
||||
expect(isPrivateIPv4('192.168.0.1')).toBe(true);
|
||||
expect(isPrivateIPv4('192.168.255.255')).toBe(true);
|
||||
});
|
||||
it('169.254.x はプライベート', () => {
|
||||
expect(isPrivateIPv4('169.254.0.1')).toBe(true);
|
||||
});
|
||||
it('グローバル IP はプライベートではない', () => {
|
||||
expect(isPrivateIPv4('8.8.8.8')).toBe(false);
|
||||
expect(isPrivateIPv4('1.1.1.1')).toBe(false);
|
||||
expect(isPrivateIPv4('203.0.113.1')).toBe(false);
|
||||
});
|
||||
it('範囲外・不正な形式は false', () => {
|
||||
expect(isPrivateIPv4('999.999.999.999')).toBe(false);
|
||||
expect(isPrivateIPv4('abc')).toBe(false);
|
||||
expect(isPrivateIPv4('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPrivateIPv6', () => {
|
||||
it('::1 はプライベート', () => {
|
||||
expect(isPrivateIPv6('::1')).toBe(true);
|
||||
});
|
||||
it('fc/fd プレフィックスはプライベート', () => {
|
||||
expect(isPrivateIPv6('fc00::1')).toBe(true);
|
||||
expect(isPrivateIPv6('fd12:3456::1')).toBe(true);
|
||||
});
|
||||
it('ブラケット付きでも動作する', () => {
|
||||
expect(isPrivateIPv6('[::1]')).toBe(true);
|
||||
});
|
||||
it('グローバル IPv6 はプライベートではない', () => {
|
||||
expect(isPrivateIPv6('2001:db8::1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHostAllowed', () => {
|
||||
it('allowedHosts に含まれていれば true', () => {
|
||||
expect(isHostAllowed('example.com', ['example.com', 'test.com'])).toBe(true);
|
||||
});
|
||||
it('含まれていなければ false', () => {
|
||||
expect(isHostAllowed('evil.com', ['example.com'])).toBe(false);
|
||||
});
|
||||
it('空リストなら常に false', () => {
|
||||
expect(isHostAllowed('example.com', [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkSSRF', () => {
|
||||
it('localhost はブロック(allowedHosts にない場合)', async () => {
|
||||
await expect(checkSSRF('localhost', [])).rejects.toThrow('SSRF blocked');
|
||||
});
|
||||
it('localhost は allowedHosts に含まれていれば許可', async () => {
|
||||
await expect(checkSSRF('localhost', ['localhost'])).resolves.toBeUndefined();
|
||||
});
|
||||
it('allowedHosts に含まれるホストは許可', async () => {
|
||||
await expect(checkSSRF('example.com', ['example.com'])).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import * as dns from 'dns';
|
||||
import { isPrivateOrForbidden } from '../../../net/ssrf-strict.js';
|
||||
|
||||
// These delegate to the hardened range check in src/net/ssrf-strict.ts so that
|
||||
// WebFetch / DownloadFile / BrowseWeb get the same coverage as MCP and SSH:
|
||||
// loopback, RFC1918, link-local + cloud metadata (169.254/16, fd00:ec2::),
|
||||
// CGNAT (100.64/10), 0.0.0.0/8, IPv4-mapped IPv6, NAT64, multicast, reserved.
|
||||
export function isPrivateIPv4(ip: string): boolean {
|
||||
return isPrivateOrForbidden(ip, 4);
|
||||
}
|
||||
|
||||
export function isPrivateIPv6(ip: string): boolean {
|
||||
const normalized = ip.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
return isPrivateOrForbidden(normalized, 6);
|
||||
}
|
||||
|
||||
export function isHostAllowed(hostname: string, allowedHosts: string[]): boolean {
|
||||
return allowedHosts.includes(hostname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Block requests to private/forbidden destinations.
|
||||
*
|
||||
* Resolves ALL addresses for the hostname (not just the first) and rejects if
|
||||
* any resolves to a private/forbidden range. An explicit allowlist entry
|
||||
* bypasses the check (used for trusted internal hosts).
|
||||
*/
|
||||
export async function checkSSRF(hostname: string, allowedHosts: string[]): Promise<void> {
|
||||
if (hostname === 'localhost' && !isHostAllowed(hostname, allowedHosts)) {
|
||||
throw new Error(`SSRF blocked: hostname "localhost" is not allowed`);
|
||||
}
|
||||
if (isHostAllowed(hostname, allowedHosts)) {
|
||||
return;
|
||||
}
|
||||
let addrs: Array<{ address: string; family: number }>;
|
||||
try {
|
||||
addrs = await dns.promises.lookup(hostname, { all: true });
|
||||
} catch (e) {
|
||||
throw new Error(`DNS resolution failed for "${hostname}": ${(e as Error).message}`);
|
||||
}
|
||||
if (addrs.length === 0) {
|
||||
throw new Error(`SSRF blocked: "${hostname}" resolved to no addresses`);
|
||||
}
|
||||
for (const a of addrs) {
|
||||
if (isPrivateOrForbidden(a.address, a.family as 4 | 6)) {
|
||||
throw new Error(`SSRF blocked: "${hostname}" resolves to forbidden IP "${a.address}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SSRF-safe fetch that re-validates every redirect hop.
|
||||
*
|
||||
* `fetch`'s default redirect following re-resolves DNS and would happily
|
||||
* follow a 30x to http://169.254.169.254/ (cloud metadata) or an internal
|
||||
* host. This follows redirects manually and runs `checkSSRF` against each
|
||||
* Location before requesting it, so a public URL cannot bounce the request
|
||||
* into a private destination.
|
||||
*
|
||||
* Residual: this does not pin the resolved IP, so a sub-second DNS-rebinding
|
||||
* attacker can still race the validation lookup against the connection lookup.
|
||||
* Full pinning (as in src/net/ssrf-strict.ts#pinnedFetch) is the follow-up;
|
||||
* this closes the redirect path, which is the practically exploitable one.
|
||||
*/
|
||||
export async function ssrfSafeFetch(
|
||||
url: string,
|
||||
allowedHosts: string[],
|
||||
init: RequestInit = {},
|
||||
maxRedirects = 5,
|
||||
): Promise<Response> {
|
||||
let current = url;
|
||||
for (let hop = 0; hop <= maxRedirects; hop++) {
|
||||
const parsed = new URL(current);
|
||||
await checkSSRF(parsed.hostname, allowedHosts);
|
||||
const res = await fetch(current, { ...init, redirect: 'manual' });
|
||||
const location = res.status >= 300 && res.status < 400 ? res.headers.get('location') : null;
|
||||
if (!location) {
|
||||
return res;
|
||||
}
|
||||
// Resolve relative redirects against the current URL.
|
||||
current = new URL(location, current).toString();
|
||||
}
|
||||
throw new Error('SSRF blocked: too many redirects');
|
||||
}
|
||||
Reference in New Issue
Block a user