diff --git a/pds-check/package.json b/pds-check/package.json index 28803e1..db2b464 100644 --- a/pds-check/package.json +++ b/pds-check/package.json @@ -9,8 +9,10 @@ }, "devDependencies": { "@atcute/lexicons": "^2.1.1", + "@types/node": "^26.6.5", "better-result": "^3.0.1", "hono": "4.13.13", + "ipaddr.js": "^2.5.0", "valibot": "^1.5.0", "wrangler": "4.151.3" }, diff --git a/pds-check/src/hosts.ts b/pds-check/src/hosts.ts new file mode 100644 index 0000000..7005a88 --- /dev/null +++ b/pds-check/src/hosts.ts @@ -0,0 +1,56 @@ +import { resolve4, resolve6 } from 'node:dns/promises'; +import { Result } from 'better-result'; +import ipaddr from 'ipaddr.js'; + +function isIPv6(addr: ipaddr.IPv4 | ipaddr.IPv6): addr is ipaddr.IPv6 { + return addr.kind() === 'ipv6'; +} + +function isPublicIp(ip: string) { + if (!ipaddr.isValid(ip)) return false; + let addr = ipaddr.parse(ip); + if (isIPv6(addr) && addr.isIPv4MappedAddress()) { + addr = addr.toIPv4Address(); // ::ffff:127.0.0.1 -> 127.0.0.1 + } + return addr.range() === 'unicast'; // everything special-purpose is rejected +} + +const BLOCKED_SUFFIXES = [ + '.local', + '.localhost', + '.internal', + '.lan', + '.home.arpa', + '.test', + '.invalid', +]; + +async function resolvesPublic(host: string) { + // oxlint-disable-next-line unicorn/no-await-expression-member + const addrs = (await Promise.allSettled([resolve4(host), resolve6(host)])) + .filter((r) => r.status === 'fulfilled') + .flatMap((r) => r.value); + + return addrs.length > 0 && addrs.every((a) => isPublicIp(a)); +} + +/** + * Check whether a given hostname is a public domain, or ip address. + * This can be used to protect against `fetch` calls to these addresses, + * however since we can't use the result of the dns lookup we perform + * DNS rebinding attacks are still possible - although the risk is acceptable. + * + * @param hostname - hostname to check + */ +export async function isPublicHost(hostname: string) { + // oxlint-disable-next-line e18e/prefer-static-regex + let host = hostname.toLowerCase().replace(/\.$/, ''); + if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1); + + if (ipaddr.isValid(host)) return Result.ok(isPublicIp(host)); + if (!host.includes('.')) return Result.ok(false); + if (host === 'localhost') return Result.ok(false); + if (BLOCKED_SUFFIXES.some((s) => host.endsWith(s))) return Result.ok(false); + + return await Result.tryPromise(async () => await resolvesPublic(host)); +} diff --git a/pds-check/src/index.ts b/pds-check/src/index.ts index 7a2e7f7..1871d02 100644 --- a/pds-check/src/index.ts +++ b/pds-check/src/index.ts @@ -1,6 +1,7 @@ +import { describeServer } from './xrpc'; +import { isPublicHost } from './hosts'; import { logger } from 'hono/logger'; import { Hono } from 'hono'; -import { describeServer } from './xrpc'; const app = new Hono<{ Bindings: Env }>(); @@ -23,6 +24,15 @@ app.get('/v0/check', async (c) => { }); } + const publicHost = await isPublicHost(url.hostname); + + if (publicHost.isErr() || !publicHost.value) { + return c.json({ + valid: false, + reason: 'url is invalid', + }); + } + const result = await describeServer(url); if (!result.valid) return c.json(result); diff --git a/pds-check/tsconfig.json b/pds-check/tsconfig.json index 1ffd8b0..0d20943 100644 --- a/pds-check/tsconfig.json +++ b/pds-check/tsconfig.json @@ -9,7 +9,7 @@ "checkJs": true, "resolveJsonModule": true, "skipLibCheck": true, - "types": ["./worker-configuration.d.ts"], + "types": ["./worker-configuration.d.ts", "node"], "lib": ["esnext"] }, "include": ["./src/**.ts", "./tests/**.ts", "./worker-configuration.d.ts"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c266d13..a54aa99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,12 +89,18 @@ importers: '@atcute/lexicons': specifier: ^2.1.1 version: 2.1.1 + '@types/node': + specifier: ^26.6.5 + version: 26.6.5 better-result: specifier: ^3.0.1 version: 3.0.1 hono: specifier: 4.13.13 version: 4.13.13 + ipaddr.js: + specifier: ^2.5.0 + version: 2.5.0 valibot: specifier: ^1.5.0 version: 1.5.0(typescript@6.0.3) @@ -1342,6 +1348,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ipaddr.js@2.5.0: + resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} + engines: {node: '>= 10'} + is-reference@3.0.3: resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} @@ -2733,6 +2743,8 @@ snapshots: internmap@2.0.3: {} + ipaddr.js@2.5.0: {} + is-reference@3.0.3: dependencies: '@types/estree': 1.0.9