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:
@@ -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),
|
||||
})),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user