Commit 5: Scanner-Engine (DNS, Portscan, Titel/Favicon, Softwareerkennung, FritzBox TR-064)

This commit is contained in:
2026-07-19 02:22:54 +02:00
parent 129b4253b5
commit 28bccb9d16
13 changed files with 744 additions and 18 deletions

View File

@@ -0,0 +1,139 @@
import { createHash, randomBytes } from "node:crypto";
import http, { type IncomingHttpHeaders } from "node:http";
import https from "node:https";
export interface DigestAuthOptions {
host: string;
port: number;
https?: boolean;
username: string;
password: string;
timeoutMs?: number;
}
interface DigestChallenge {
realm: string;
nonce: string;
qop?: string;
opaque?: string;
}
function md5(input: string): string {
return createHash("md5").update(input).digest("hex");
}
function parseDigestHeader(header: string): DigestChallenge {
const params: Record<string, string> = {};
const regex = /(\w+)=("([^"]*)"|[^,]*)/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(header))) {
params[match[1]] = match[3] ?? match[2];
}
return { realm: params.realm, nonce: params.nonce, qop: params.qop, opaque: params.opaque };
}
interface RawResponse {
status: number;
headers: IncomingHttpHeaders;
body: string;
}
function rawRequest(
options: DigestAuthOptions,
path: string,
headers: Record<string, string>,
body: string
): Promise<RawResponse> {
return new Promise((resolve, reject) => {
const client = options.https ? https : http;
const req = client.request(
{
host: options.host,
port: options.port,
path,
method: "POST",
headers: { ...headers, "Content-Length": Buffer.byteLength(body) },
timeout: options.timeoutMs ?? 4000,
rejectUnauthorized: false,
},
(res) => {
let data = "";
res.on("data", (chunk: Buffer) => (data += chunk.toString("utf-8")));
res.on("end", () =>
resolve({ status: res.statusCode ?? 0, headers: res.headers, body: data })
);
res.on("error", reject);
}
);
req.on("timeout", () => {
req.destroy();
reject(new Error("Zeitüberschreitung bei der Verbindung zur FritzBox"));
});
req.on("error", reject);
req.write(body);
req.end();
});
}
/**
* Führt einen TR-064-SOAP-Request gegen `/upnp/control/hosts` aus.
* Läuft zunächst unauthentifiziert; antwortet die FritzBox mit 401 und einer
* WWW-Authenticate-Digest-Challenge, wird automatisch mit korrekt berechnetem
* Digest-Response-Header erneut angefragt (RFC 2617).
*/
export async function soapRequest(
options: DigestAuthOptions,
soapAction: string,
body: string
): Promise<string> {
const path = "/upnp/control/hosts";
const baseHeaders = {
"Content-Type": 'text/xml; charset="utf-8"',
SOAPACTION: soapAction,
};
const first = await rawRequest(options, path, baseHeaders, body);
if (first.status === 200) {
return first.body;
}
if (first.status !== 401) {
throw new Error(`Unerwarteter Status von der FritzBox: ${first.status}`);
}
const wwwAuth = first.headers["www-authenticate"];
if (!wwwAuth) {
throw new Error("FritzBox verlangt Authentifizierung, sendet aber keine Digest-Challenge");
}
const challenge = parseDigestHeader(Array.isArray(wwwAuth) ? wwwAuth[0] : wwwAuth);
if (!challenge.realm || !challenge.nonce) {
throw new Error("Digest-Challenge der FritzBox konnte nicht gelesen werden");
}
const ha1 = md5(`${options.username}:${challenge.realm}:${options.password}`);
const ha2 = md5(`POST:${path}`);
const nc = "00000001";
const cnonce = randomBytes(8).toString("hex");
const response = challenge.qop
? md5(`${ha1}:${challenge.nonce}:${nc}:${cnonce}:${challenge.qop}:${ha2}`)
: md5(`${ha1}:${challenge.nonce}:${ha2}`);
const authHeader =
`Digest username="${options.username}", realm="${challenge.realm}", ` +
`nonce="${challenge.nonce}", uri="${path}", response="${response}"` +
(challenge.qop ? `, qop=${challenge.qop}, nc=${nc}, cnonce="${cnonce}"` : "") +
(challenge.opaque ? `, opaque="${challenge.opaque}"` : "");
const second = await rawRequest(
options,
path,
{ ...baseHeaders, Authorization: authHeader },
body
);
if (second.status !== 200) {
throw new Error(`FritzBox-Authentifizierung fehlgeschlagen (Status ${second.status})`);
}
return second.body;
}