diff --git a/README.md b/README.md index 43d5ddd..5097548 100644 --- a/README.md +++ b/README.md @@ -355,6 +355,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 @@ -620,13 +644,18 @@ Dazu kommen - ein **Suchfeld** über Name, Ziel, Kurz-URL und Typ, - eine **Sortierung** nach Datum, Name oder Scan-Anzahl, -- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen. +- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen, +- ein **Filter nach Art**, sobald dynamische und statische Codes + nebeneinander liegen. Auf schmalen Bildschirmen blendet die Liste die hinteren Spalten aus; die Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die Kartenansicht. -Statische Codes erscheinen hier nicht, weil sie nicht gespeichert werden. +Abgelegte **statische Codes** stehen mit in der Liste, tragen aber ein +Abzeichen „statisch“ statt einer Kurz-URL und zählen keine Scans. Steht der +Filter auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung nach +Scans mit ihr — bei einer Liste aus lauter Nullen sagt sie nichts. Bei einem **Feedback-Code** führt aus der Zeile ein Stern-Symbol zur Auswertung: Durchschnitt, Verteilung der Noten und die abgegebenen Kommentare. diff --git a/docs/funktionen.md b/docs/funktionen.md index c135543..6293aef 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -122,6 +122,17 @@ Liste steht dort „statisch“ und ein Strich. Wer nach dem Ablegen den Inhalt ändert, bekommt das Ablegen erneut angeboten — der vorherige Eintrag bleibt als eigener Schnappschuss bestehen. +**Dublettenprüfung.** Liegt in derselben Gruppe schon ein statischer Code mit +identischem Inhalt, hält das Ablegen an und zeigt, welcher Eintrag das ist und +wann er entstand. Der Knopf heißt dann **Trotzdem ablegen** — eine Warnung, +keine Sperre: Zwei Fassungen desselben Codes können gewollt sein. + +Verglichen wird nur der Inhalt, nicht die Gestaltung. Derselbe Code in +Schwarz-Weiß und in Hausfarben gilt also als Dublette — was er streng +genommen auch ist, denn gescannt wird beides Mal dasselbe. Dynamische Codes +bleiben außen vor: Zwei Kurz-Links auf dasselbe Ziel sind der Normalfall, +sobald zwei Kampagnen getrennt gezählt werden sollen. + ### Ein Logo in der Mitte Ein Bild in die Mitte des Codes setzen, wie man es von Firmen-Codes kennt. 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 182c735..4e002d8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -270,6 +270,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/(app)/meine-codes/page.tsx b/src/app/(app)/meine-codes/page.tsx index 26bcf7d..12357a9 100644 --- a/src/app/(app)/meine-codes/page.tsx +++ b/src/app/(app)/meine-codes/page.tsx @@ -27,9 +27,9 @@ export default async function MyCodesPage() { Meine Codes
- Alle dynamischen QR-Codes mit ihrem aktuellen Ziel und der Anzahl der - Scans. Statische Codes erscheinen hier nicht — sie werden nicht - gespeichert. + Dynamische Codes mit ihrem aktuellen Ziel und der Anzahl der Scans, + dazu abgelegte statische Codes. Über die Auswahl „Nach Art filtern“ + lässt sich die Liste auf eine der beiden Arten einschränken.
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- Kein Code passt zur Suche.{" "} + Kein Code passt zu Suche und Filtern.{" "}