From 69c34c4f0c974d3d71c45695862edc92a7ad0fd1 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 21 Aug 2026 03:06:54 +0200 Subject: [PATCH] Domain-Check: fuehrt die Domain wirklich hierher? MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eine Domain zu registrieren ist der kleinste Teil der Arbeit — DNS, Reverse Proxy und Zertifikat liegen ausserhalb dieser Anwendung. Fehlt dort etwas, merkt man es am gedruckten Code, der nicht aufgeht. Der Knopf "Pruefen" geht den Weg der Scanner nach: Namen aufloesen, Zertifikat ansehen, Adresse aufrufen — und sich beweisen lassen, dass am anderen Ende diese Installation steht. Der Beweis ist ein HMAC ueber eine frische Zufallszahl; das Geheimnis dafuer entsteht beim ersten Check und verlaesst den Server nie. Geprueft wird nur ein Hostname aus der Datenbank, nie einer aus der Anfrage — sonst waere die Route ein Werkzeug zum Abklopfen fremder Adressen. Scheitert schon DNS, fallen die drei Folgeschritte aus, statt dreimal dasselbe zu melden. Die Grenze der Aussage steht im Bericht selbst: Der Aufruf startet dort, wo der Container laeuft. Refs #7 --- README.md | 24 + docs/sicherheit.md | 23 + .../20260821010000_domain_check/migration.sql | 7 + prisma/schema.prisma | 8 + src/app/api/admin/domains/[id]/check/route.ts | 43 ++ src/app/api/domain-check/route.ts | 40 ++ src/components/domain-admin.tsx | 40 ++ src/components/domain-bericht.tsx | 70 +++ src/lib/domain-check.ts | 478 ++++++++++++++++++ 9 files changed, 733 insertions(+) create mode 100644 prisma/migrations/20260821010000_domain_check/migration.sql create mode 100644 src/app/api/admin/domains/[id]/check/route.ts create mode 100644 src/app/api/domain-check/route.ts create mode 100644 src/components/domain-bericht.tsx create mode 100644 src/lib/domain-check.ts diff --git a/README.md b/README.md index 86826ca..c1f750d 100644 --- a/README.md +++ b/README.md @@ -353,6 +353,30 @@ prüft ihn gegen die registrierten Domains. Ein unbekannter Hostname endet mit 404 — über einen gefälschten Header lässt sich also keine fremde Domain ansprechen. +### Nachprüfen, ob eine Domain wirklich hierher führt + +Auf der Domain-Seite steht bei jedem Eintrag ein Knopf **Prüfen**. Er geht den +Weg der Scanner nach und meldet vier Dinge: + +| Schritt | Was geprüft wird | +| --- | --- | +| DNS | Löst der Name auf? Auf welche Adressen? | +| Zertifikat | Gültig, von wem ausgestellt, wie lange noch? | +| Erreichbar | Antwortet dort ein Webserver, und wie schnell? | +| Ziel | Steht dahinter **diese** Installation? | + +Der letzte Schritt ist der eigentliche Wert. Die Anwendung ruft dazu unter der +Domain eine eigene Route auf und lässt sich einen HMAC über eine frische +Zufallszahl zurückgeben; das Geheimnis dafür steht in der Datenbank und +verlässt den Server nie. Nur so unterscheidet sich „die Domain zeigt +irgendwohin“ von „die Domain zeigt hierher“. + +> **Grenze der Aussage:** Der Aufruf startet dort, wo der Container läuft. +> Wer internes DNS oder Hairpin-NAT hat, bekommt womöglich einen Fehler, +> obwohl von außen alles stimmt. Umgekehrt gilt: Meldet die Prüfung von innen +> Erfolg, heißt das noch nicht, dass die Domain auch von außen erreichbar ist. +> Dafür bleibt der Blick von einem fremden Netz aus. + Zwei Dinge, die daraus folgen: - Der **Slug ist je Domain eindeutig**, nicht global. Derselbe sprechende diff --git a/docs/sicherheit.md b/docs/sicherheit.md index 62aa2b5..366ab25 100644 --- a/docs/sicherheit.md +++ b/docs/sicherheit.md @@ -191,6 +191,29 @@ Konto nur bei **bestätigter** E-Mail-Adresse. --- +## Der Domain-Check und seine Grenzen + +Auf der Domain-Seite ruft der Knopf **Prüfen** die eingetragene Domain vom +Server aus auf. Drei Dinge grenzen das ein: + +- Geprüft wird ausschließlich ein Hostname **aus der Datenbank**, nie einer + aus der Anfrage. Sonst wäre die Route ein Werkzeug, mit dem sich vom Server + aus beliebige Adressen abklopfen ließen. +- Auslösen darf sie nur, wer `instance:manage` hat — dieselbe Berechtigung, + die auch Domains anlegt. +- Die Route, die den Beweis liefert (`/api/domain-check`), ist **ohne + Anmeldung** erreichbar; der Aufruf kommt ja von außen. Sie gibt nichts + preis: Die Antwort ist ein HMAC über die mitgeschickte Zufallszahl. Ohne + das Geheimnis lässt er sich nicht bilden, und aus ihm folgt nichts über das + Geheimnis. Es entsteht beim ersten Check und verlässt den Server nie. + +Beim Zertifikat läuft die Verbindung bewusst mit `rejectUnauthorized: false`. +Nur so lässt sich ein schlechtes Zertifikat **beschreiben** — abgelaufen, +selbst ausgestellt, falscher Name — statt bloß daran zu scheitern. +Übertragen wird über diese Verbindung nichts. + +--- + ## Offen Bewusst nicht umgesetzt, mit Begründung. diff --git a/prisma/migrations/20260821010000_domain_check/migration.sql b/prisma/migrations/20260821010000_domain_check/migration.sql new file mode 100644 index 0000000..9645111 --- /dev/null +++ b/prisma/migrations/20260821010000_domain_check/migration.sql @@ -0,0 +1,7 @@ +-- Der Domain-Check ruft die Domain von aussen auf und prueft, ob er dabei bei +-- dieser Installation landet. Beweis dafuer ist ein HMAC ueber eine +-- Zufallszahl, gebildet mit einem Geheimnis, das den Server nie verlaesst. +-- +-- Nullable und ohne Vorbelegung: Der Wert entsteht beim ersten Check. Eine +-- feste Vorbelegung waere fuer alle Installationen dieselbe und damit wertlos. +ALTER TABLE "InstanceSetting" ADD COLUMN "checkSecret" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 898c2c5..8e24816 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -262,6 +262,14 @@ model InstanceSetting { /// entwertet im Zweifel gedruckte Codes oder besetzt Adressen, die /// jemand anderes gebraucht hätte. Administratoren können das freigeben. editorsMaySetSlug Boolean @default(false) + /// Geheimnis fuer den Domain-Check. + /// + /// Die Pruefung ruft die Domain von aussen auf und laesst sich einen HMAC + /// ueber eine Zufallszahl zurueckgeben. Nur wer dieses Geheimnis kennt, + /// kann ihn bilden — so steht fest, dass wirklich diese Installation + /// geantwortet hat und nicht irgendein anderer Server. Es verlaesst den + /// Server nie und entsteht beim ersten Check. + checkSecret String? updatedAt DateTime @updatedAt } diff --git a/src/app/api/admin/domains/[id]/check/route.ts b/src/app/api/admin/domains/[id]/check/route.ts new file mode 100644 index 0000000..950af1e --- /dev/null +++ b/src/app/api/admin/domains/[id]/check/route.ts @@ -0,0 +1,43 @@ +import { getActor } from "@/lib/auth/actor"; +import { requirePermission } from "@/lib/auth/policy"; +import { prisma } from "@/lib/db"; +import { pruefeDomain } from "@/lib/domain-check"; +import { errorResponse } from "@/lib/http"; +import { QrRequestError } from "@/lib/qr/type-module"; + +export const dynamic = "force-dynamic"; + +type Context = { params: Promise<{ id: string }> }; + +/** + * Prüft eine registrierte Domain: DNS, Zertifikat, Erreichbarkeit — und ob + * dahinter wirklich diese Installation steht. + * + * `POST`, obwohl nichts gespeichert wird: Der Aufruf stößt eine Verbindung + * nach außen an und ist nichts, was sich vorsorglich abrufen lässt. + * + * Geprüft wird ausschließlich der Hostname aus der Datenbank. Ein Hostname + * aus der Anfrage würde diese Route zum Werkzeug machen, mit dem sich vom + * Server aus beliebige Adressen abklopfen lassen. + */ +export async function POST( + _request: Request, + context: Context, +): Promise { + try { + requirePermission(await getActor(), "instance:manage"); + const { id } = await context.params; + + const domain = await prisma.domain.findUnique({ + where: { id }, + select: { hostname: true }, + }); + if (!domain) { + throw new QrRequestError("Diese Domain gibt es nicht.", 404); + } + + return Response.json({ report: await pruefeDomain(domain.hostname) }); + } catch (error) { + return errorResponse(error); + } +} diff --git a/src/app/api/domain-check/route.ts b/src/app/api/domain-check/route.ts new file mode 100644 index 0000000..70c5fcb --- /dev/null +++ b/src/app/api/domain-check/route.ts @@ -0,0 +1,40 @@ +import type { NextRequest } from "next/server"; + +import { bildeBeweis, getCheckSecret } from "@/lib/domain-check"; + +export const dynamic = "force-dynamic"; + +/** + * Beweis-Route für den Domain-Check. + * + * Bewusst **ohne Anmeldung**: Der Aufruf kommt von außen über die zu prüfende + * Domain und trägt keine Sitzung mit sich. Preisgegeben wird dabei nichts — + * die Antwort ist ein HMAC über die mitgeschickte Zufallszahl. Wer das + * Geheimnis nicht kennt, kann ihn nicht bilden; wer ihn sieht, lernt daraus + * nichts über das Geheimnis. + * + * Dass es diese Anwendung gibt, verrät ohnehin schon jede Landingpage. + */ +export async function GET(request: NextRequest): Promise { + const nonce = request.nextUrl.searchParams.get("nonce") ?? ""; + + // Eine Grenze nach oben, damit die Route nicht als Rechenknecht für fremde + // Daten taugt, und eine nach unten, damit sie nicht auf Zuruf antwortet. + if (!/^[a-f0-9]{16,64}$/.test(nonce)) { + return Response.json( + { error: "Es fehlt eine gültige Zufallszahl." }, + { status: 400 }, + ); + } + + try { + const proof = bildeBeweis(await getCheckSecret(), nonce); + return Response.json( + { app: "qrcode-generator", proof }, + { headers: { "Cache-Control": "no-store" } }, + ); + } catch (error) { + console.error("Domain-Check konnte keinen Beweis bilden:", error); + return Response.json({ error: "Nicht möglich." }, { status: 500 }); + } +} diff --git a/src/components/domain-admin.tsx b/src/components/domain-admin.tsx index e10e403..d04d5a4 100644 --- a/src/components/domain-admin.tsx +++ b/src/components/domain-admin.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; +import { DomainBericht } from "@/components/domain-bericht"; import { Icon } from "@/components/icons"; import { Modal } from "@/components/modal"; import { @@ -14,6 +15,7 @@ import { TextInput, } from "@/components/ui"; import { ApiError, apiJson } from "@/lib/api-client"; +import type { Pruefbericht } from "@/lib/domain-check"; type Grant = { groupId: string; groupName: string; isDefault: boolean }; @@ -44,6 +46,10 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) { const [deleting, setDeleting] = useState(null); + /** Prüfberichte je Domain — bleiben stehen, bis erneut geprüft wird. */ + const [reports, setReports] = useState>({}); + const [checking, setChecking] = useState(null); + const load = useCallback(async () => { try { const result = await apiJson<{ @@ -122,6 +128,26 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) { } }; + const check = async (domain: AdminDomain) => { + setChecking(domain.id); + setError(null); + try { + const { report } = await apiJson<{ report: Pruefbericht }>( + `/api/admin/domains/${domain.id}/check`, + { method: "POST" }, + ); + setReports((bisher) => ({ ...bisher, [domain.id]: report })); + } catch (caught) { + setError( + caught instanceof ApiError + ? caught.message + : "Die Prüfung ließ sich nicht ausführen.", + ); + } finally { + setChecking(null); + } + }; + if (domains === null) { return

Domains werden geladen …

; } @@ -192,6 +218,16 @@ export function DomainAdmin({ baseUrlHostname }: { baseUrlHostname: string }) { Gruppen zuteilen +