mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 21:41:16 +00:00
124 lines
4.9 KiB
TypeScript
124 lines
4.9 KiB
TypeScript
import "server-only"
|
|
import dns from "node:dns"
|
|
import http from "node:http"
|
|
import https from "node:https"
|
|
import net from "node:net"
|
|
|
|
export type CheckResult = {
|
|
ok: boolean
|
|
statusCode: number | null
|
|
responseMs: number | null
|
|
error: string | null
|
|
}
|
|
|
|
const TIMEOUT_MS = 8000
|
|
|
|
// Nur öffentliche Ziele: schützt davor, über den Check interne Dienste (Metadata-API, localhost, LAN) anzusprechen.
|
|
const blocked = new net.BlockList()
|
|
for (const [address, prefix] of [
|
|
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16],
|
|
["172.16.0.0", 12], ["192.0.0.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["224.0.0.0", 4], ["240.0.0.0", 4],
|
|
] as const) {
|
|
blocked.addSubnet(address, prefix, "ipv4")
|
|
}
|
|
for (const [address, prefix] of [["::", 128], ["::1", 128], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8]] as const) {
|
|
blocked.addSubnet(address, prefix, "ipv6")
|
|
}
|
|
|
|
function isBlockedAddress(address: string, family: number) {
|
|
const mappedV4 = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address)?.[1]
|
|
if (mappedV4) return blocked.check(mappedV4, "ipv4")
|
|
return blocked.check(address, family === 6 ? "ipv6" : "ipv4")
|
|
}
|
|
|
|
// Wird von http(s).request für jede Verbindung genutzt, also auch gegen DNS-Rebinding zwischen Prüfung und Connect.
|
|
const safeLookup: net.LookupFunction = (hostname, options, callback) => {
|
|
dns.lookup(hostname, { ...options, all: true }, (error, addresses) => {
|
|
if (error) return callback(error, "", 4)
|
|
const list = addresses as dns.LookupAddress[]
|
|
const forbidden = list.find((entry) => isBlockedAddress(entry.address, entry.family))
|
|
if (list.length === 0 || forbidden) {
|
|
return callback(Object.assign(new Error("Ziel nicht erlaubt"), { code: "EBLOCKED" }), "", 4)
|
|
}
|
|
if (options.all) return (callback as unknown as (err: null, addresses: dns.LookupAddress[]) => void)(null, list)
|
|
callback(null, list[0].address, list[0].family)
|
|
})
|
|
}
|
|
|
|
const DOMAIN_PATTERN = /^(https?:\/\/)?[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}(:\d{1,5})?(\/\S*)?$/i
|
|
|
|
// "www.firma.de" oder "https://firma.de/shop" → prüfbar; "Server", "info@firma.de" → nicht.
|
|
export function toCheckableUrl(value: string | null): URL | null {
|
|
if (!value) return null
|
|
const trimmed = value.trim()
|
|
if (trimmed.includes("@") || !DOMAIN_PATTERN.test(trimmed)) return null
|
|
try {
|
|
const url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`)
|
|
return url.protocol === "http:" || url.protocol === "https:" ? url : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function describeError(error: NodeJS.ErrnoException) {
|
|
switch (error.code) {
|
|
case "EBLOCKED": return "Ziel nicht erlaubt"
|
|
case "ENOTFOUND": case "EAI_AGAIN": return "Domain nicht gefunden"
|
|
case "ECONNREFUSED": return "Verbindung abgelehnt"
|
|
case "ECONNRESET": return "Verbindung abgebrochen"
|
|
case "ETIMEDOUT": return "Zeitüberschreitung"
|
|
default:
|
|
return error.code?.startsWith("ERR_TLS") || error.code?.includes("CERT")
|
|
? "SSL-Zertifikat ungültig"
|
|
: "Nicht erreichbar"
|
|
}
|
|
}
|
|
|
|
export function checkUrl(url: URL): Promise<CheckResult> {
|
|
// Bei IP-Literalen ruft Node den lookup-Hook nicht auf, daher hier separat prüfen.
|
|
const host = url.hostname.replace(/^\[|\]$/g, "")
|
|
const ipFamily = net.isIP(host)
|
|
if (ipFamily !== 0 && isBlockedAddress(host, ipFamily)) {
|
|
return Promise.resolve({ ok: false, statusCode: null, responseMs: null, error: "Ziel nicht erlaubt" })
|
|
}
|
|
|
|
const client = url.protocol === "http:" ? http : https
|
|
const started = performance.now()
|
|
|
|
return new Promise((resolve) => {
|
|
let settled = false
|
|
const finish = (result: CheckResult) => {
|
|
if (settled) return
|
|
settled = true
|
|
resolve(result)
|
|
}
|
|
|
|
const request = client.request(
|
|
url,
|
|
{
|
|
method: "GET",
|
|
lookup: safeLookup,
|
|
timeout: TIMEOUT_MS,
|
|
headers: { "User-Agent": "Since3-Statuscheck/1.0", Accept: "text/html,*/*" },
|
|
},
|
|
(response) => {
|
|
const statusCode = response.statusCode ?? 0
|
|
response.destroy()
|
|
finish({
|
|
ok: statusCode > 0 && statusCode < 400,
|
|
statusCode,
|
|
responseMs: Math.round(performance.now() - started),
|
|
error: statusCode >= 400 ? `HTTP ${statusCode}` : null,
|
|
})
|
|
}
|
|
)
|
|
|
|
request.on("timeout", () => {
|
|
request.destroy(Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }))
|
|
})
|
|
request.on("error", (error: NodeJS.ErrnoException) => {
|
|
finish({ ok: false, statusCode: null, responseMs: null, error: describeError(error) })
|
|
})
|
|
request.end()
|
|
})
|
|
}
|