feat: initial public release (MAESTRO)

This commit is contained in:
oss-sync
2026-06-03 05:08:00 +00:00
commit f5c7666f6b
823 changed files with 184150 additions and 0 deletions
+54
View File
@@ -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>&amp; &lt; &gt; &quot; &#39; &nbsp;</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('');
});
});
+32
View File
@@ -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(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/&nbsp;/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;
}
+76
View File
@@ -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();
});
});
+84
View File
@@ -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');
}