Files
tessera-ctl/apps/api/src/nextcloud-files/nextcloud-http.ts
T
schalli 08abfef8e2 fix(nextcloud-files): WR-09/IN-02 ungueltige Pfadzeichen sind 400, Vorschau ohne SVG
- WR-09: ein einzelnes UTF-16-Ersatzzeichen in einem Pfadsegment ist 400 invalidPath (bzw.
  invalidName) statt eines URIError mit 500; die Weboberflaeche wiederholt keine 4xx
- IN-02: Vorschaubilder nur als Rasterbild, image/svg+xml wird wie ein fehlendes
  Vorschaubild behandelt (CSP-Sandbox und nosniff bleiben)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 22:42:31 +02:00

489 lines
18 KiB
TypeScript

import type { Readable } from 'node:stream';
import { request } from 'undici';
import { normalizeCloudUrl } from '../nextcloud-status/nextcloud-status-fetch';
import type { NextcloudCallGate } from './nextcloud-call-gate';
import { type NcFailureKind, ncErrorDefault } from './nextcloud-files.types';
/**
* Einzige Transportschicht des Moduls "Nextcloud-Dateien" (quick-261008-mzu).
* Jeder Aufruf an eine Nextcloud geht durch `ncRequest` — dort sitzen die
* Schutzregeln, damit sie kein Aufrufer umgehen kann.
*
* Warum interne Adressen erlaubt sind (L-01): die Nextcloud steht oft im Haus,
* und die Adresse setzt allein ein Verwalter. Der gemeinsame Schutz
* `isPublicHttpUrl` (nur oeffentliche Adressen) wird deshalb hier mit Absicht
* NICHT verwendet. Stattdessen wird die Reichweite so eingegrenzt:
* - Es gibt genau EINE Basisadresse je Organisation (Normalform von
* `normalizeCloudUrl`). Jede URL ist `Basis + fester Pfadanfang +
* einzeln codierte Segmente`; der Pfadanfang kommt aus einer festen Liste.
* - Benutzereingaben sind nur Pfadsegmente. Sie laufen durch
* `validateSegment` und werden Stueck fuer Stueck mit `encodeURIComponent`
* codiert (nie ein ganzer Pfad auf einmal).
* - Es werden KEINE Weiterleitungen befolgt (jedes 3xx ist ein Fehler), und
* nie wird eine URL aus einer Nextcloud-Antwort aufgerufen.
* - Es werden nie Cookies gesendet; `set-cookie` wird nie weitergereicht.
* - Zeit- und Groessengrenzen auf jedem Aufruf. Zertifikate werden immer
* geprueft (es gibt keinen Schalter, das abzustellen; eine interne CA
* gehoert in `NODE_EXTRA_CA_CERTS` des api-Containers).
* - Ein Adresswechsel laesst alle gespeicherten App-Passwoerter ablaufen,
* das Konto fuehrt die Adresse mit, fuer die das Passwort galt.
*
* Warum undici `request` und nicht `fetch`: Node-Streams als Anfragekoerper
* ohne Umweg, kein Weiterleitungsfolgen von sich aus, die Antwort kommt als
* lesbarer Strom, der mit `stream.pipeline` weitergereicht werden kann.
*
* Nie loggen: Kopfzeilen, Zugangsdaten, App-Passwoerter. Fehlerergebnisse
* tragen nur Art, Statuscode und eine kurze Kennung (z. B. `ENOTFOUND`).
*/
export const NC_USER_AGENT = 'Tessera (Nextcloud-Dateien)';
/** Nest-Token fuer den einsetzbaren Transport (Tests setzen eine Attrappe ein). */
export const NEXTCLOUD_TRANSPORT = 'NEXTCLOUD_TRANSPORT';
/** Erlaubte feste Pfadanfaenge (D-C). Alles andere wirft vor jedem Aufruf. */
export const ALLOWED_PREFIXES = [
'/status.php',
'/ocs/v2.php/',
'/index.php/login/v2',
'/index.php/login/v2/poll',
'/index.php/core/preview',
'/remote.php/dav/files/',
'/remote.php/dav/uploads/',
'/index.php/apps/theming/image/logo',
'/core/img/logo/logo.svg',
] as const;
export type NcPrefix = (typeof ALLOWED_PREFIXES)[number];
export const DEFAULT_HEADERS_TIMEOUT_MS = 15_000;
export const DEFAULT_BODY_TIMEOUT_MS = 15_000;
// --- Transport -------------------------------------------------------------------
export type NcHeaders = Record<string, string | string[] | undefined>;
export interface NcTransportRequest {
url: string;
method: string;
headers: Record<string, string>;
body?: Readable | string | Buffer | null;
headersTimeoutMs: number;
bodyTimeoutMs: number;
signal: AbortSignal;
}
export interface NcTransportResponse {
statusCode: number;
headers: NcHeaders;
body: Readable;
}
export type NextcloudTransport = (req: NcTransportRequest) => Promise<NcTransportResponse>;
/** Standardtransport auf Basis von undici `request` (keine Cookies, keine Weiterleitungen). */
export const undiciTransport: NextcloudTransport = async (req) => {
const res = await request(req.url, {
// undici kennt PROPFIND/MKCOL/MOVE zur Laufzeit; die Typen nennen nur die Standardmethoden.
method: req.method as never,
headers: req.headers,
body: (req.body ?? undefined) as never,
headersTimeout: req.headersTimeoutMs,
bodyTimeout: req.bodyTimeoutMs,
signal: req.signal,
});
return {
statusCode: res.statusCode,
headers: res.headers as NcHeaders,
body: res.body as unknown as Readable,
};
};
// --- Pfade und URLs -----------------------------------------------------------------
const MAX_SEGMENT_BYTES = 255;
const MAX_SEGMENTS = 100;
const MAX_PATH_CHARS = 4096;
/** Ein einzelnes UTF-16-Ersatzzeichen ohne Partner (z. B. `"\uD800"` aus einem JSON-Koerper). */
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
/**
* Ein einzelnes Pfadsegment pruefen (D-H): nicht leer, nicht `.`/`..`, kein
* `/`, `\`, NUL oder Steuerzeichen, kein einzelnes Ersatzzeichen (WR-09: daran
* scheitert `encodeURIComponent` mit einem URIError, der sonst als 500 endete),
* hoechstens 255 UTF-8-Byte.
*/
export function validateSegment(segment: string): string {
if (
typeof segment !== 'string' ||
segment === '' ||
segment === '.' ||
segment === '..' ||
// biome-ignore lint/suspicious/noControlCharactersInRegex: Steuerzeichen sind hier gerade der Prueffall
/[\\/\u0000-\u001f\u007f]/.test(segment) ||
LONE_SURROGATE.test(segment) ||
Buffer.byteLength(segment, 'utf8') > MAX_SEGMENT_BYTES
) {
throw ncErrorDefault('invalidPath');
}
return segment;
}
/** Jedes Segment einzeln mit `encodeURIComponent` codieren und mit `/` verbinden. */
export function encodeSegments(segments: readonly string[]): string {
return segments.map((s) => encodeURIComponent(validateSegment(s))).join('/');
}
/**
* Pfad aus Query/Body in Segmente zerlegen. `''` und `/` sind die Wurzel; ein
* fuehrender und ein abschliessender `/` werden entfernt; jedes Segment laeuft
* durch `validateSegment`; mehr als 100 Segmente oder 4096 Zeichen sind
* ungueltig. Namen bleiben unveraendert (keine Unicode-Normalisierung).
*/
export function parseUserPath(raw: string | undefined | null): string[] {
if (raw === undefined || raw === null) return [];
if (typeof raw !== 'string' || raw.length > MAX_PATH_CHARS) throw ncErrorDefault('invalidPath');
let path = raw;
if (path.startsWith('/')) path = path.slice(1);
if (path.endsWith('/')) path = path.slice(0, -1);
if (path === '') return [];
const segments = path.split('/');
if (segments.length > MAX_SEGMENTS) throw ncErrorDefault('invalidPath');
return segments.map(validateSegment);
}
/** Neue Namen (Ordner anlegen, Umbenennen): Segmentregeln plus `.part` und Leerraum. */
export function validateNewName(name: string): string {
try {
validateSegment(name);
} catch {
throw ncErrorDefault('invalidName');
}
if (name.trim() === '' || name.toLowerCase().endsWith('.part')) {
throw ncErrorDefault('invalidName');
}
return name;
}
/**
* Baut die URL `Basis + Pfadanfang + codierte Segmente (+ Query)`. Die Basis
* muss die Normalform von `normalizeCloudUrl` haben; der Pfadanfang muss in
* `ALLOWED_PREFIXES` stehen. Verstoesse sind Programmierfehler und werfen.
*/
export function buildNcUrl(
baseUrl: string,
prefix: NcPrefix,
segments: readonly string[] = [],
query?: Record<string, string>,
trailingSlash = false,
): string {
if (!(ALLOWED_PREFIXES as readonly string[]).includes(prefix)) {
throw new Error(`Nextcloud-Pfadanfang nicht erlaubt: ${prefix}`);
}
const base = normalizeCloudUrl(baseUrl);
if (base === null) throw new Error('Nextcloud-Basisadresse ist ungültig');
if (segments.length > 0 && !prefix.endsWith('/')) {
throw new Error(`Nextcloud-Pfadanfang ${prefix} nimmt keine Segmente`);
}
let url = `${base}${prefix}${encodeSegments(segments)}`;
// Ordner-Adressen enden fuer WebDAV mit einem Schraegstrich (PROPFIND auf Ordner).
if (trailingSlash && !url.endsWith('/')) url += '/';
if (query) {
const pairs = Object.entries(query).map(
([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`,
);
if (pairs.length > 0) url += `?${pairs.join('&')}`;
}
return url;
}
/** `Basic base64(benutzer:geheimnis)` — das Ergebnis nie loggen oder zurueckgeben. */
export function basicAuth(user: string, secret: string): string {
return `Basic ${Buffer.from(`${user}:${secret}`, 'utf8').toString('base64')}`;
}
// --- Aufruf -----------------------------------------------------------------------------
export interface NcRequestOptions {
baseUrl: string;
prefix: NcPrefix;
segments?: readonly string[];
query?: Record<string, string>;
/** Haengt der Adresse einen `/` an (Ordner bei WebDAV). */
trailingSlash?: boolean;
method: string;
headers?: Record<string, string>;
body?: Readable | string | Buffer | null;
/** Fertiger `Authorization`-Wert (siehe `basicAuth`). */
authorization?: string;
/** Gesetzt bei Aufrufen mit gespeichertem App-Passwort (siehe Aufrufsperre). */
credentialKey?: string;
/**
* Nicht unter die Grenze gleichzeitiger Aufrufe je Schluessel (WR-03) fallen: nur fuer
* Uebertragungen, deren Antwort erst nach dem ganzen Koerper kommt (PUT eines Stuecks,
* Zusammenbau). Sie laufen ohnehin nacheinander je Datei.
*/
unthrottled?: boolean;
/** OCS-Aufruf: setzt `OCS-APIRequest` und `Accept: application/json`. */
ocs?: boolean;
headersTimeoutMs?: number;
bodyTimeoutMs?: number;
signal?: AbortSignal;
}
export interface NcFailure {
ok: false;
kind: NcFailureKind;
status?: number;
retryAfterSeconds?: number;
/** Kurze eigene Kennung wie `ENOTFOUND` — nie ein Antworttext. */
detail?: string;
}
export type NcResult = { ok: true; status: number; headers: NcHeaders; body: Readable } | NcFailure;
const TLS_CODES = new Set([
'CERT_HAS_EXPIRED',
'DEPTH_ZERO_SELF_SIGNED_CERT',
'SELF_SIGNED_CERT_IN_CHAIN',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
'ERR_TLS_CERT_ALTNAME_INVALID',
'ERR_TLS_CERT_ALTNAME_INVALID_ALTERNATE',
'CERT_NOT_YET_VALID',
'CERT_UNTRUSTED',
'CERT_REVOKED',
'CERT_SIGNATURE_FAILURE',
'HOSTNAME_MISMATCH',
]);
const TIMEOUT_CODES = new Set([
'UND_ERR_HEADERS_TIMEOUT',
'UND_ERR_BODY_TIMEOUT',
'UND_ERR_CONNECT_TIMEOUT',
'ETIMEDOUT',
]);
function errorCode(err: unknown): string | null {
const e = err as { code?: unknown; cause?: { code?: unknown } } | null;
const code = e?.cause?.code ?? e?.code;
return typeof code === 'string' && /^[A-Z0-9_]{2,80}$/.test(code) ? code : null;
}
/** Ordnet einen Transportfehler einer Fehlerart zu (ohne den Fehlertext zu uebernehmen). */
export function classifyTransportError(err: unknown): NcFailure {
const code = errorCode(err);
if (code && TLS_CODES.has(code)) return { ok: false, kind: 'tls', detail: code };
if (code && TIMEOUT_CODES.has(code)) return { ok: false, kind: 'timeout', detail: code };
return { ok: false, kind: 'network', ...(code ? { detail: code } : {}) };
}
function headerOne(value: string | string[] | undefined): string | undefined {
if (Array.isArray(value)) return value[0];
return value;
}
/** Antwortkoerper verwerfen, ohne einen Fehler nach aussen zu geben. */
export function discardBody(body: Readable | null | undefined): void {
if (!body) return;
try {
const dumpable = body as unknown as { dump?: () => Promise<void> };
if (typeof dumpable.dump === 'function') {
dumpable.dump().catch(() => {});
} else {
body.destroy();
}
} catch {
// schon verbraucht
}
}
const FORBIDDEN_CUSTOM_HEADERS = new Set(['cookie', 'host', 'authorization']);
/**
* Fuehrt einen Aufruf aus. Wirft nie bei Netz- oder HTTP-Problemen; das
* Ergebnis ist `{ ok: true, status, headers, body }` oder `{ ok: false, kind, ... }`.
* HTTP-Fehlerstatus (404, 412, ...) kommen als `ok: true` zurueck, damit der
* Aufrufer sie deuten kann. Ausnahmen, die hier schon entschieden werden:
* - 429: haelt den ganzen Ursprung an (Aufrufsperre) -> `{ ok: false, kind: 'http', status: 429 }`
* - 401 mit `credentialKey`: Schluessel stirbt -> `kind: 'credential-dead'`
* - 3xx: `kind: 'redirect'` (es wird nie gefolgt)
* Vor dem Transport prueft sie die Aufrufsperre (`paused`, `credential-dead`).
*/
export async function ncRequest(
transport: NextcloudTransport,
gate: NextcloudCallGate,
opts: NcRequestOptions,
): Promise<NcResult> {
const url = buildNcUrl(
opts.baseUrl,
opts.prefix,
opts.segments ?? [],
opts.query,
opts.trailingSlash ?? false,
);
const origin = new URL(url).origin;
if (opts.credentialKey && gate.isDead(opts.credentialKey)) {
return { ok: false, kind: 'credential-dead' };
}
const pause = gate.isPaused(origin);
if (pause.paused) {
return { ok: false, kind: 'paused', retryAfterSeconds: pause.retryAfterSeconds };
}
// WR-03: hoechstens 4 gleichzeitige Aufrufe je Zugangsschluessel bis zur Antwort. Ein
// widerrufener Zugang erzeugt so hoechstens ein paar 401, bevor er als tot gilt.
let releaseSlot: (() => void) | undefined;
if (opts.credentialKey && !opts.unthrottled) {
try {
releaseSlot = await gate.acquireSlot(opts.credentialKey, opts.signal);
} catch {
return { ok: false, kind: 'aborted' };
}
// Waehrend des Wartens kann der Schluessel gestorben oder der Ursprung gesperrt worden sein.
if (gate.isDead(opts.credentialKey)) {
releaseSlot();
return { ok: false, kind: 'credential-dead' };
}
const later = gate.isPaused(origin);
if (later.paused) {
releaseSlot();
return { ok: false, kind: 'paused', retryAfterSeconds: later.retryAfterSeconds };
}
}
try {
return await sendNcRequest(transport, gate, opts, url, origin);
} finally {
// Erst NACH der Auswertung (401 -> tot, 429 -> Sperre) freigeben: der naechste Wartende
// sieht den neuen Zustand, bevor er sendet.
releaseSlot?.();
}
}
async function sendNcRequest(
transport: NextcloudTransport,
gate: NextcloudCallGate,
opts: NcRequestOptions,
url: string,
origin: string,
): Promise<NcResult> {
const headers: Record<string, string> = { 'user-agent': NC_USER_AGENT };
if (opts.ocs) {
headers['ocs-apirequest'] = 'true';
headers.accept = 'application/json';
}
for (const [name, value] of Object.entries(opts.headers ?? {})) {
const lower = name.toLowerCase();
if (FORBIDDEN_CUSTOM_HEADERS.has(lower)) {
throw new Error(`Kopfzeile ${lower} darf nicht frei gesetzt werden`);
}
headers[lower] = value;
}
if (opts.authorization) headers.authorization = opts.authorization;
const own = new AbortController();
const signals: AbortSignal[] = [own.signal];
if (opts.signal) signals.push(opts.signal);
if (opts.credentialKey) signals.push(gate.signalFor(opts.credentialKey));
const signal = AbortSignal.any(signals);
const headersTimeoutMs = opts.headersTimeoutMs ?? DEFAULT_HEADERS_TIMEOUT_MS;
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
own.abort(new Error('timeout'));
}, headersTimeoutMs);
// Auch ein Transport, der das Signal nicht beachtet, soll nicht haengen bleiben.
let onAbort: (() => void) | undefined;
const aborted = new Promise<never>((_, reject) => {
onAbort = () => reject(signal.reason ?? new Error('aborted'));
if (signal.aborted) onAbort();
else signal.addEventListener('abort', onAbort, { once: true });
});
aborted.catch(() => {});
const pending = Promise.resolve().then(() =>
transport({
url,
method: opts.method,
headers,
body: opts.body ?? null,
headersTimeoutMs,
bodyTimeoutMs: opts.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS,
signal,
}),
);
pending.catch(() => {});
let res: NcTransportResponse;
try {
res = await Promise.race([pending, aborted]);
} catch (err) {
// Eine spaet eintreffende Antwort des abgebrochenen Aufrufs wegwerfen.
pending.then((late) => discardBody(late.body)).catch(() => {});
if (opts.credentialKey && gate.isDead(opts.credentialKey)) {
return { ok: false, kind: 'credential-dead' };
}
if (timedOut) return { ok: false, kind: 'timeout' };
if (opts.signal?.aborted) return { ok: false, kind: 'aborted' };
return classifyTransportError(err);
} finally {
clearTimeout(timer);
if (onAbort) signal.removeEventListener('abort', onAbort);
}
const status = res.statusCode;
if (status === 429) {
const seconds = gate.pause(origin, headerOne(res.headers['retry-after']));
discardBody(res.body);
return { ok: false, kind: 'http', status, retryAfterSeconds: seconds };
}
if (status === 401 && opts.credentialKey) {
gate.markDead(opts.credentialKey);
discardBody(res.body);
return { ok: false, kind: 'credential-dead', status };
}
if (status >= 300 && status < 400) {
discardBody(res.body);
return { ok: false, kind: 'redirect', status };
}
return { ok: true, status, headers: res.headers, body: res.body };
}
export type CappedText =
| { ok: true; text: string }
| { ok: false; kind: 'too-large' | 'timeout' | 'network' | 'tls'; detail?: string };
/** Liest einen Antwortkoerper als Text, hoechstens `maxBytes` (sonst `too-large`). */
export async function readCappedText(
body: AsyncIterable<Uint8Array | string> & { destroy?: (err?: Error) => unknown },
maxBytes: number,
): Promise<CappedText> {
const chunks: Buffer[] = [];
let total = 0;
try {
for await (const chunk of body) {
const buf = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : Buffer.from(chunk);
total += buf.byteLength;
if (total > maxBytes) {
try {
body.destroy?.();
} catch {
// schon beendet
}
return { ok: false, kind: 'too-large' };
}
chunks.push(buf);
}
} catch (err) {
const failure = classifyTransportError(err);
return {
ok: false,
kind: failure.kind as 'timeout' | 'network' | 'tls',
...(failure.detail ? { detail: failure.detail } : {}),
};
}
return { ok: true, text: Buffer.concat(chunks).toString('utf8') };
}