Files
goodbuddy/src/main/browser/browser-url-policy.ts
T
2026-08-07 10:45:01 +08:00

147 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { lookup as dnsLookup } from 'node:dns/promises'
import { isIP } from 'node:net'
export type BrowserResolvedAddress = {
address: string
family: 4 | 6
}
export type BrowserDnsResolver = (
hostname: string,
signal: AbortSignal
) => Promise<readonly BrowserResolvedAddress[]>
export type ValidatedBrowserUrl = {
url: URL
origin: string
addresses: readonly BrowserResolvedAddress[]
}
export function canonicalizeBrowserUrl(input: string): URL {
if (input !== input.trim() || input.length === 0 || input.length > 8_192) {
throw new Error('浏览器 URL 无效')
}
let url: URL
try {
url = new URL(input)
} catch {
throw new Error('浏览器 URL 无效')
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('浏览器仅支持 HTTP(S) URL')
}
if (!url.hostname || url.origin === 'null') {
throw new Error('浏览器 URL 缺少有效主机名')
}
url.hash = ''
return url
}
async function defaultResolver(
hostname: string,
signal: AbortSignal
): Promise<readonly BrowserResolvedAddress[]> {
signal.throwIfAborted()
const result = await dnsLookup(hostname, {
all: true,
verbatim: true
})
signal.throwIfAborted()
return result
.filter(
(entry): entry is { address: string; family: 4 | 6 } =>
entry.family === 4 || entry.family === 6
)
.map((entry) => ({ address: entry.address, family: entry.family }))
}
export class BrowserUrlPolicy {
constructor(
private readonly resolveDns: BrowserDnsResolver = defaultResolver,
private readonly resolutionTimeoutMs = 10_000
) {
if (
!Number.isSafeInteger(resolutionTimeoutMs) ||
resolutionTimeoutMs < 1
) {
throw new Error('浏览器 DNS 解析期限无效')
}
}
private async resolve(
hostname: string,
signal: AbortSignal
): Promise<readonly BrowserResolvedAddress[]> {
const timeout = AbortSignal.timeout(this.resolutionTimeoutMs)
const effectiveSignal = AbortSignal.any([signal, timeout])
const resolution = this.resolveDns(hostname, effectiveSignal)
return new Promise((resolve, reject) => {
const abort = (): void => {
reject(
signal.aborted
? signal.reason
: new Error(`浏览器 DNS 解析超时(${this.resolutionTimeoutMs}ms)`)
)
}
effectiveSignal.addEventListener('abort', abort, { once: true })
void resolution.then(
(addresses) => {
effectiveSignal.removeEventListener('abort', abort)
if (effectiveSignal.aborted) {
abort()
} else {
resolve(addresses)
}
},
(error: unknown) => {
effectiveSignal.removeEventListener('abort', abort)
reject(error)
}
)
})
}
/**
* Resolves the target up front so the filtering proxy connects to the exact
* addresses seen here instead of re-resolving, which keeps a host from
* pointing at a different machine between approval and connection.
*/
async validate(
input: string | URL,
signal: AbortSignal
): Promise<ValidatedBrowserUrl> {
signal.throwIfAborted()
const url = canonicalizeBrowserUrl(
typeof input === 'string' ? input : input.toString()
)
const literalHostname =
url.hostname.startsWith('[') && url.hostname.endsWith(']')
? url.hostname.slice(1, -1)
: url.hostname
const literalFamily = isIP(literalHostname)
const addresses =
literalFamily === 4 || literalFamily === 6
? [{
address: literalHostname,
family: literalFamily
} as const]
: await this.resolve(url.hostname, signal)
signal.throwIfAborted()
if (addresses.length === 0) {
throw new Error('浏览器目标无法解析到任何地址')
}
return {
url,
origin: url.origin,
addresses: [...addresses]
}
}
async validateRedirect(
input: string,
signal: AbortSignal
): Promise<ValidatedBrowserUrl> {
return this.validate(input, signal)
}
}