diff --git a/frontend/src/logic/__tests__/safeRemoteHtml.test.ts b/frontend/src/logic/__tests__/safeRemoteHtml.test.ts index 8d26275..72752bd 100644 --- a/frontend/src/logic/__tests__/safeRemoteHtml.test.ts +++ b/frontend/src/logic/__tests__/safeRemoteHtml.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { createPinnedLookup, fetchSafeRemoteHtml, isPublicIpAddress } from '@/logic/safeRemoteHtml'; +import { + createPinnedRequestOptions, + fetchSafeRemoteHtml, + isPublicIpAddress, +} from '@/logic/safeRemoteHtml'; describe('safeRemoteHtml', () => { it.each([ @@ -13,6 +17,7 @@ describe('safeRemoteHtml', () => { 'fd00::1', 'fe80::1', '::ffff:127.0.0.1', + '::ffff:7f00:1', ])('内部・予約IP %s を拒否する', (address) => { expect(isPublicIpAddress(address)).toBe(false); }); @@ -24,46 +29,43 @@ describe('safeRemoteHtml', () => { }, ); - it.each(['http://127.0.0.1/private', 'http://[::1]/private', 'ftp://example.com/file'])( + it.each([ + 'http://127.0.0.1/private', + 'http://[::1]/private', + 'http://[::ffff:7f00:1]/private', + 'ftp://example.com/file', + ])( '危険な取得先 %s を接続前に拒否する', async (url) => { await expect(fetchSafeRemoteHtml(url)).rejects.toMatchObject({ status: expect.any(Number) }); }, ); - it('Node.jsのall lookupでは固定IPを配列形式で返す', async () => { + it('検証済みIPへ直接接続し、元ホスト名をHostとTLS SNIに限定する', () => { const pinned = { address: '1.1.1.1', family: 4 as const }; - const lookup = createPinnedLookup(pinned); + const options = createPinnedRequestOptions( + new URL('https://example.com:8443/path?q=value'), + pinned, + ); - await new Promise((resolve, reject) => { - lookup('example.com', { all: true }, (error, address, family) => { - try { - expect(error).toBeNull(); - expect(address).toEqual([pinned]); - expect(family).toBeUndefined(); - resolve(); - } catch (assertionError) { - reject(assertionError); - } - }); + expect(options).toMatchObject({ + hostname: pinned.address, + family: pinned.family, + port: '8443', + path: '/path?q=value', + method: 'GET', + servername: 'example.com', + headers: expect.objectContaining({ host: 'example.com:8443' }), }); }); - it('単一lookupでは固定IPとfamilyを従来形式で返す', async () => { - const pinned = { address: '1.1.1.1', family: 4 as const }; - const lookup = createPinnedLookup(pinned); - - await new Promise((resolve, reject) => { - lookup('example.com', { all: false }, (error, address, family) => { - try { - expect(error).toBeNull(); - expect(address).toBe(pinned.address); - expect(family).toBe(pinned.family); - resolve(); - } catch (assertionError) { - reject(assertionError); - } - }); + it('IPリテラルへのHTTPS接続ではSNIへIPを設定しない', () => { + const options = createPinnedRequestOptions(new URL('https://1.1.1.1/'), { + address: '1.1.1.1', + family: 4, }); + + expect(options.hostname).toBe('1.1.1.1'); + expect(options.servername).toBeUndefined(); }); }); diff --git a/frontend/src/logic/safeRemoteHtml.ts b/frontend/src/logic/safeRemoteHtml.ts index 1f007a1..bc7b294 100644 --- a/frontend/src/logic/safeRemoteHtml.ts +++ b/frontend/src/logic/safeRemoteHtml.ts @@ -3,7 +3,6 @@ import type { LookupAddress } from 'node:dns'; import http from 'node:http'; import https from 'node:https'; import net from 'node:net'; -import type { LookupFunction } from 'node:net'; const MAX_URL_LENGTH = 2048; const MAX_REDIRECTS = 3; @@ -58,8 +57,9 @@ function isBlockedIpv6(address: string): boolean { return true; } - const ipv4Mapped = normalized.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1]; - return ipv4Mapped ? isBlockedIpv4(ipv4Mapped) : false; + // Reject the entire IPv4-mapped range, including hexadecimal forms such as + // ::ffff:7f00:1, so private IPv4 destinations cannot bypass textual checks. + return normalized.startsWith('::ffff:'); } export function isPublicIpAddress(address: string): boolean { @@ -69,16 +69,6 @@ export function isPublicIpAddress(address: string): boolean { return false; } -export function createPinnedLookup(pinned: LookupAddress): LookupFunction { - return (_hostname, options, callback) => { - if (options.all) { - callback(null, [pinned]); - return; - } - callback(null, pinned.address, pinned.family); - }; -} - function parseRemoteUrl(rawUrl: string): URL { if (rawUrl.length > MAX_URL_LENGTH) { throw new SafeRemoteHtmlError('URL is too long', 400); @@ -125,6 +115,31 @@ async function resolvePublicAddress(url: URL): Promise<{ address: string; family return { address: selected.address, family: selected.family as 4 | 6 }; } +export function createPinnedRequestOptions( + url: URL, + pinned: { address: string; family: 4 | 6 }, +): https.RequestOptions { + const originalHostname = url.hostname.startsWith('[') && url.hostname.endsWith(']') + ? url.hostname.slice(1, -1) + : url.hostname; + + return { + hostname: pinned.address, + family: pinned.family, + port: url.port || undefined, + path: `${url.pathname}${url.search}`, + method: 'GET', + headers: { + accept: 'text/html,application/xhtml+xml;q=0.9', + host: url.host, + 'user-agent': 'Rito OGP Fetcher/1.0', + }, + ...(url.protocol === 'https:' && net.isIP(originalHostname) === 0 + ? { servername: originalHostname } + : {}), + }; +} + function requestHtml(url: URL, pinned: { address: string; family: 4 | 6 }): Promise<{ statusCode: number; headers: http.IncomingHttpHeaders; @@ -133,15 +148,7 @@ function requestHtml(url: URL, pinned: { address: string; family: 4 | 6 }): Prom return new Promise((resolve, reject) => { const transport = url.protocol === 'https:' ? https : http; const req = transport.request( - url, - { - method: 'GET', - headers: { - accept: 'text/html,application/xhtml+xml;q=0.9', - 'user-agent': 'Rito OGP Fetcher/1.0', - }, - lookup: createPinnedLookup(pinned), - }, + createPinnedRequestOptions(url, pinned), (res) => { const declaredLength = Number(res.headers['content-length'] || 0); if (declaredLength > MAX_HTML_BYTES) {