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 +