generated from Dicken/dickendock
Commit 5: Scanner-Engine (DNS, Portscan, Titel/Favicon, Softwareerkennung, FritzBox TR-064)
This commit is contained in:
139
apps/backend/src/scanner/digestAuth.ts
Normal file
139
apps/backend/src/scanner/digestAuth.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user