Phase B2: mehrere Domains fuer Kurz-URLs

Eine Domain kann mehreren Gruppen dienen (n:m ueber DomainGrant), und der
Slug eines Codes ist nur noch je Domain eindeutig - derselbe sprechende
Slug darf also auf zwei Domains liegen.

Sicherheitsrelevanter Kern: die Weiterleitung liest den Hostnamen aus
X-Forwarded-Host bzw. Host und prueft ihn gegen die Domain-Tabelle. Ein
unbekannter Hostname endet mit 404, faellt also auf die Standard-Domain
zurueck und findet dort nichts. Ueber einen gefaelschten Header laesst sich
damit keine fremde Domain ansprechen. Auch der Hostname fuer die Vorschau
kommt aus der Datenbank, nicht aus der Anfrage.

- Migration legt die Spalte erst nullable an, fuellt sie mit der
  Standard-Domain und macht sie danach verpflichtend; der globale
  Slug-Index weicht dem zusammengesetzten aus (domainId, slug).
- Verwaltung -> Domains: registrieren, Gruppen zuteilen, Voreinstellung je
  Gruppe, abschalten. Domains mit Codes lassen sich weder loeschen noch
  einer Gruppe entziehen, die noch Codes darunter hat - beides wuerde
  gedruckte Codes ins Leere laufen lassen.

Ein Fehler dabei gefunden und behoben: die Migration kennt BASE_URL nicht
und legt die Standard-Domain mit einem Platzhalter-Hostnamen an. Korrigiert
wurde er bisher nur beim Oeffnen der Domain-Verwaltung - auf einer frischen
Installation waeren also Kurz-URLs auf 'standard.invalid' entstanden.
ensureDefaultDomain() laeuft jetzt vor jeder Verwendung.

Geprueft: 34 neue Pruefungen fuer Host-Aufloesung, Spoofing, Slug je Domain
und Zuteilung; dazu 280 bestehende bei ausgeschalteter Anmeldung.
This commit is contained in:
Florian Wilde
2026-08-14 13:10:12 +02:00
parent f1cdb3da13
commit 2a80f15e20
17 changed files with 1168 additions and 15 deletions
+4 -1
View File
@@ -29,6 +29,7 @@ function asRecord(value: unknown): Record<string, unknown> {
/** Ein Code samt der Beziehungen, die für die Anzeige gebraucht werden. */
export type QrCodeWithRelations = QrCode & {
group?: { name: string } | null;
domain?: { hostname: string } | null;
createdBy?: { name: string | null; email: string } | null;
};
@@ -57,7 +58,7 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
type: code.type as QrTypeId,
label: code.label,
data,
shortUrl: shortUrl(code.slug),
shortUrl: shortUrl(code.slug, code.domain?.hostname),
target,
summary,
design: {
@@ -73,6 +74,8 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
updatedAt: code.updatedAt.toISOString(),
groupId: code.groupId,
groupName: code.group?.name ?? null,
domainId: code.domainId,
domainHostname: code.domain?.hostname ?? null,
createdByName: code.createdBy
? (code.createdBy.name?.trim() || code.createdBy.email)
: null,
+228
View File
@@ -0,0 +1,228 @@
import "server-only";
import { prisma } from "@/lib/db";
import type { Actor } from "@/lib/auth/actor";
import { can, visibleGroupIds, writableGroupIds } from "@/lib/auth/policy";
import { getBaseUrl } from "@/lib/env";
import { QrRequestError } from "@/lib/qr/type-module";
/**
* Domains für Kurz-URLs.
*
* Die Anwendung läuft hinter einem Reverse Proxy und kann mehrere Domains
* bedienen. Welche gemeint ist, verrät der `Host`-Header der Anfrage. Weil
* dieser Header fälschbar ist, wird er **immer** gegen diese Tabelle geprüft:
* ein unbekannter Hostname führt zu 404. Damit bringt Fälschen nichts —
* ansprechen lässt sich nur, was ohnehin registriert ist.
*/
/** Von der Migration angelegte Standard-Domain. */
export const DEFAULT_DOMAIN_ID = "dom_standard";
const PLACEHOLDER_HOSTNAME = "standard.invalid";
/** Normalisiert einen Hostnamen: klein, ohne Schema, ohne abschließenden Punkt. */
export function normalizeHostname(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/^[a-z][a-z0-9+.-]*:\/\//, "")
.replace(/\/.*$/, "")
.replace(/\.$/, "");
}
/** Hostname (ggf. mit Port) aus BASE_URL. */
export function baseUrlHostname(): string {
try {
return new URL(getBaseUrl()).host.toLowerCase();
} catch {
return "localhost:3000";
}
}
/**
* Sorgt dafür, dass es die Standard-Domain gibt und ihr Hostname zu BASE_URL
* passt. Die Migration kann BASE_URL nicht kennen und setzt deshalb erst
* einen Platzhalter.
*/
export async function ensureDefaultDomain(): Promise<{
id: string;
hostname: string;
}> {
const hostname = baseUrlHostname();
const byHostname = await prisma.domain.findUnique({
where: { hostname },
select: { id: true, hostname: true },
});
if (byHostname) return byHostname;
const standard = await prisma.domain.findUnique({
where: { id: DEFAULT_DOMAIN_ID },
select: { id: true, hostname: true },
});
// Nur den unveränderten Platzhalter überschreiben — hat jemand die
// Standard-Domain bewusst umbenannt, bleibt das so.
if (standard && standard.hostname === PLACEHOLDER_HOSTNAME) {
return prisma.domain.update({
where: { id: DEFAULT_DOMAIN_ID },
data: { hostname },
select: { id: true, hostname: true },
});
}
if (standard) return standard;
return prisma.domain.create({
data: { id: DEFAULT_DOMAIN_ID, hostname, label: "Standard-Domain" },
select: { id: true, hostname: true },
});
}
/**
* Ermittelt die Domain, unter der eine Anfrage hereinkam.
*
* `X-Forwarded-Host` hat Vorrang, weil hinter einem Reverse Proxy dort der
* ursprüngliche Hostname steht. Beide Werte werden gegen die Domain-Tabelle
* geprüft — ohne Treffer gibt es kein Ergebnis.
*/
export async function resolveRequestDomain(
headers: Headers,
): Promise<{ id: string; hostname: string; isActive: boolean } | null> {
const candidates = [
headers.get("x-forwarded-host"),
headers.get("host"),
]
.filter((value): value is string => Boolean(value))
// Bei mehreren Proxies steht in X-Forwarded-Host eine Liste.
.map((value) => normalizeHostname(value.split(",")[0]));
for (const hostname of candidates) {
if (!hostname) continue;
const domain = await prisma.domain.findUnique({
where: { hostname },
select: { id: true, hostname: true, isActive: true },
});
if (domain) return domain;
}
return null;
}
export type DomainOption = {
id: string;
hostname: string;
label: string | null;
isActive: boolean;
isDefault: boolean;
};
/** Domains, die einer Gruppe zugeteilt sind. */
export async function listDomainsForGroup(
groupId: string,
): Promise<DomainOption[]> {
const grants = await prisma.domainGrant.findMany({
where: { groupId, domain: { isActive: true } },
include: { domain: true },
orderBy: [{ isDefault: "desc" }, { domain: { hostname: "asc" } }],
});
return grants.map((grant) => ({
id: grant.domain.id,
hostname: grant.domain.hostname,
label: grant.domain.label,
isActive: grant.domain.isActive,
isDefault: grant.isDefault,
}));
}
/** Alle Domains, die der Actor in Auswahllisten sehen darf. */
export async function listDomainsForActor(
actor: Actor,
): Promise<DomainOption[]> {
const visible = visibleGroupIds(actor);
const domains = await prisma.domain.findMany({
where:
visible === null
? undefined
: { grants: { some: { groupId: { in: visible } } } },
orderBy: { hostname: "asc" },
include: visible === null ? undefined : { grants: true },
});
return domains.map((domain) => ({
id: domain.id,
hostname: domain.hostname,
label: domain.label,
isActive: domain.isActive,
isDefault: domain.id === DEFAULT_DOMAIN_ID,
}));
}
/**
* Bestimmt die Domain für einen neuen Code und prüft, dass die Gruppe sie
* überhaupt nutzen darf.
*/
export async function resolveTargetDomain(
actor: Actor,
groupId: string,
requestedDomainId?: string,
): Promise<string> {
// Muss vor jeder Verwendung laufen: die Migration legt die Standard-Domain
// mit einem Platzhalter-Hostnamen an, weil sie BASE_URL nicht kennt. Ohne
// diesen Aufruf entstünden auf einer frischen Installation Kurz-URLs auf
// "standard.invalid" — also gedruckte Codes, die ins Leere führen.
await ensureDefaultDomain();
const allowed = await listDomainsForGroup(groupId);
if (requestedDomainId) {
const match = allowed.find((entry) => entry.id === requestedDomainId);
if (!match) {
// Instanz-Administratoren dürfen jede aktive Domain verwenden.
if (can(actor, "instance:manage")) {
const domain = await prisma.domain.findUnique({
where: { id: requestedDomainId },
select: { id: true, isActive: true },
});
if (domain?.isActive) return domain.id;
}
throw new QrRequestError(
"Diese Domain steht der gewählten Gruppe nicht zur Verfügung.",
403,
);
}
return match.id;
}
const preferred = allowed.find((entry) => entry.isDefault) ?? allowed[0];
if (preferred) return preferred.id;
// Der Gruppe wurde noch keine Domain zugeteilt — die Standard-Domain
// springt ein, damit das Anlegen nicht ohne Not scheitert.
const fallback = await ensureDefaultDomain();
return fallback.id;
}
/** Gruppen, in denen der Actor schreiben darf — für die Auswahl im Generator. */
export async function writableGroupsWithDomains(actor: Actor): Promise<
{
id: string;
name: string;
domains: DomainOption[];
}[]
> {
const writable = writableGroupIds(actor);
const groups = await prisma.group.findMany({
where: writable === null ? undefined : { id: { in: writable } },
orderBy: { name: "asc" },
select: { id: true, name: true },
});
return Promise.all(
groups.map(async (group) => ({
...group,
domains: await listDomainsForGroup(group.id),
})),
);
}
+2
View File
@@ -31,6 +31,8 @@ export type QrCodeDto = {
/** Gruppe, der der Code gehört. */
groupId: string;
groupName: string | null;
domainId: string;
domainHostname: string | null;
/** Wer ihn angelegt hat — leer bei Codes aus der Zeit ohne Anmeldung. */
createdByName: string | null;
};
+18 -3
View File
@@ -15,9 +15,24 @@ export function getBaseUrl(): string {
return raw.replace(/\/+$/, "");
}
/** Kurz-URL eines dynamischen Codes. */
export function shortUrl(slug: string): string {
return `${getBaseUrl()}/r/${slug}`;
/**
* Kurz-URL eines dynamischen Codes.
*
* Ohne Hostname gilt BASE_URL. Mit Hostname — dem der Code zugeordnet ist —
* wird dessen Domain verwendet; das Schema (http/https) kommt weiter aus
* BASE_URL, weil die Anwendung selbst nicht weiß, ob ein Proxy davor TLS
* beendet.
*/
export function shortUrl(slug: string, hostname?: string): string {
if (!hostname) return `${getBaseUrl()}/r/${slug}`;
let scheme = "http";
try {
scheme = new URL(getBaseUrl()).protocol.replace(":", "");
} catch {
// Unbrauchbare BASE_URL — http ist die verträglichere Annahme.
}
return `${scheme}://${hostname}/r/${slug}`;
}
export function getDatabaseUrl(): string {
+18 -1
View File
@@ -2,6 +2,7 @@ import "server-only";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { requireAvailableQrType } from "@/lib/qr/availability";
import { designSchema } from "@/lib/qr/design";
import {
@@ -33,6 +34,8 @@ export const qrRequestSchema = z.object({
mode: qrModeSchema.default("static"),
/** Slug eines bereits gespeicherten dynamischen Codes. */
slug: z.string().trim().optional(),
/** Domain, unter der die Kurz-URL entsteht. */
domainId: z.string().trim().optional(),
});
export type QrRequest = z.infer<typeof qrRequestSchema>;
@@ -97,10 +100,24 @@ export async function resolveQrRequest(
if (slug !== undefined && !isValidSlug(slug)) {
throw new QrRequestError("Ungültige Kurz-URL.");
}
// Der Hostname kommt aus der Datenbank, nicht aus der Anfrage: sonst
// ließe sich eine beliebige Fremddomain in einen Code kodieren.
let hostname: string | undefined;
if (request.domainId) {
const domain = await prisma.domain.findUnique({
where: { id: request.domainId },
select: { hostname: true },
});
if (!domain) {
throw new QrRequestError("Diese Domain gibt es nicht.", 404);
}
hostname = domain.hostname;
}
return {
qrType,
data: validated.data,
content: shortUrl(slug ?? PREVIEW_SLUG),
content: shortUrl(slug ?? PREVIEW_SLUG, hostname),
};
}