content,dns: resolve proxy fake IP addresses safely

Use DNS over HTTPS only when a host has only proxy fake IP addresses.
Keep private address checks and pinned article connections.
Test DNS failures, redirects, and interruption.
This commit is contained in:
EFLKumo committed 2026-10-11 16:21:39 +08:00
1 parent ce87e56053
commit 3751fd00d3
7 files changed
+526 -6

No files matched your search

+93 -4
View File
@@ -1,7 +1,9 @@
import { lookup } from "node:dns/promises"
import dns from "node:dns/promises"
import { isIP } from "node:net"
import { Readability } from "@mozilla/readability"
import { Effect } from "effect"
import { RecordType } from "@multiformats/dns"
import { dnsJsonOverHttps } from "@multiformats/dns/resolvers"
import { Config, Effect } from "effect"
import ipaddr from "ipaddr.js"
import { parseHTML } from "linkedom"
import { Agent, fetch } from "undici"
@@ -10,6 +12,7 @@ import { AppError } from "./errors"
const MAX_BYTES = 2_000_000
const MAX_TEXT = 60_000
const NETWORK_TIMEOUT = 20_000
const FAKE_IP_RANGE = ipaddr.IPv4.parseCIDR("198.18.0.0/15")
const redirects = new Set([301, 302, 303, 307, 308])
export function isPublicAddress(address: string): boolean {
@@ -51,6 +54,81 @@ export function canonicalUrl(value: string): string {
return url.toString()
}
const resolveProxyHostname = Effect.fn("content.resolveProxyHostname")(
function* (hostname: string) {
const endpoint = yield* Config.String("DNS_OVER_HTTPS_URL").pipe(
Config.withDefault("https://cloudflare-dns.com/dns-query"),
)
if (endpoint === "off") return []
const url = yield* Effect.try({
try: () => {
const url = new URL(endpoint)
if (
url.protocol !== "https:" ||
url.username ||
url.password ||
url.search ||
url.hash
)
throw new Error(
"Use an HTTPS DNS endpoint without credentials or parameters.",
)
return url
},
catch: (cause) =>
new AppError({
message: "Set DNS_OVER_HTTPS_URL to a valid HTTPS DNS endpoint.",
status: 500,
cause,
}),
})
const resolve = dnsJsonOverHttps(url.toString())
const responses = yield* Effect.forEach(
[RecordType.A, RecordType.AAAA],
(type) =>
Effect.tryPromise({
try: (signal) => resolve(hostname, { types: type, signal }),
catch: (cause) =>
new AppError({
message: "The public DNS lookup failed. Try again.",
status: 502,
cause,
}),
}).pipe(
Effect.filterOrFail(
(response) => response.Status === 0 && !response.TC,
() =>
new AppError({
message: "The public DNS lookup failed. Try again.",
status: 502,
}),
),
),
{ concurrency: 2 },
).pipe(
Effect.timeout("10 seconds"),
Effect.mapError((cause) =>
cause instanceof AppError
? cause
: new AppError({
message: "The public DNS lookup timed out. Try again.",
status: 502,
cause,
}),
),
)
return responses.flatMap((response) =>
response.Answer.flatMap((answer) => {
// The resolver normalizes numeric DNS record types to their names.
const type = String(answer.type)
return ["A", "AAAA", "1", "28"].includes(type)
? [{ address: answer.data, family: isIP(answer.data) }]
: []
}),
)
},
)
export const validatePublicUrl = Effect.fn("content.validatePublicUrl")(
function* (value: string) {
const url = yield* Effect.try({
@@ -73,11 +151,11 @@ export const validatePublicUrl = Effect.fn("content.validatePublicUrl")(
status: 400,
})
}
const addresses = yield* Effect.tryPromise({
let addresses = yield* Effect.tryPromise({
try: () =>
isIP(hostname)
? Promise.resolve([{ address: hostname, family: isIP(hostname) }])
: lookup(hostname, { all: true, verbatim: true }),
: dns.lookup(hostname, { all: true, verbatim: true }),
catch: (cause) =>
new AppError({
message: "The URL host could not be found.",
@@ -96,6 +174,17 @@ export const validatePublicUrl = Effect.fn("content.validatePublicUrl")(
}),
),
)
if (
!isIP(hostname) &&
addresses.length > 0 &&
addresses.every(
({ address }) =>
isIP(address) === 4 &&
ipaddr.IPv4.parse(address).match(FAKE_IP_RANGE),
)
) {
addresses = yield* resolveProxyHostname(hostname)
}
if (
!addresses.length ||
addresses.some(({ address }) => !isPublicAddress(address))