feat(api-keys): restrict keys to source IP addresses

A key can be limited to IP addresses or CIDR networks (e.g. the Wabenhain
server); a stolen key is useless from anywhere else. Requests from other
addresses get 403 "not allowed for this IP" and a log line. The admin UI
asks for the addresses when creating a key, warns when a key works from
anywhere, shows the last source address and can take it over into the
allowlist - changing the list needs no new key (PATCH /api-keys/:id).

Also: a key can only carry scopes its creator holds.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 02:41:46 +02:00
1 parent 9ae1d96a32
commit f1dc0de3e2
16 files changed
+463 -10

No files matched your search

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { parseAllowlist } from "./api-key-allowlist";
describe("parseAllowlist", () => {
it("accepts commas, spaces and new lines and drops blanks", () => {
expect(parseAllowlist(" 203.0.113.7,198.51.100.0/24\n\n2001:db8::1 ; ")).toEqual([
"203.0.113.7",
"198.51.100.0/24",
"2001:db8::1",
]);
});
it("returns an empty list for empty input", () => {
expect(parseAllowlist(" ")).toEqual([]);
});
});
@@ -0,0 +1,117 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Globe, ShieldAlert } from "lucide-react";
import { toast } from "sonner";
import { updateApiKeyAllowedIps } from "@/lib/api-keys/api";
import { ApiKey } from "@/lib/api-keys/types";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
/** "1.2.3.4, 5.6.7.0/24" or one per line -> list without blanks. */
export function parseAllowlist(text: string): string[] {
return text
.split(/[\s,;]+/)
.map((entry) => entry.trim())
.filter(Boolean);
}
function messageOf(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback;
}
/**
* Where a key may be used from. Empty means "anywhere" and is shown as a
* warning - a stolen key would then work from any computer.
*/
export function ApiKeyAllowlist({ apiKey }: { apiKey: ApiKey }) {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [text, setText] = useState(apiKey.allowedIps.join("\n"));
const mutation = useMutation({
mutationFn: (allowedIps: string[]) => updateApiKeyAllowedIps(apiKey.id, allowedIps),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["api-keys"] });
toast.success("Erlaubte Adressen gespeichert.");
setEditing(false);
},
onError: (error) => toast.error(messageOf(error, "Adressen konnten nicht gespeichert werden.")),
});
const inputId = `allowlist-${apiKey.id}`;
const lastIpMissing =
apiKey.lastUsedIp !== null && !parseAllowlist(text).includes(apiKey.lastUsedIp);
if (!editing) {
return (
<div className="flex flex-wrap items-center gap-2 text-xs">
{apiKey.allowedIps.length > 0 ? (
<span className="flex items-center gap-1 text-muted-foreground">
<Globe className="size-3.5" aria-hidden />
Nur von: <span className="font-mono">{apiKey.allowedIps.join(", ")}</span>
</span>
) : (
<span className="flex items-center gap-1 text-warning-foreground">
<ShieldAlert className="size-3.5 text-warning" aria-hidden />
Von jeder Adresse nutzbar – besser auf den Server von Wabenhain beschränken.
</span>
)}
<Button type="button" size="sm" variant="ghost" onClick={() => setEditing(true)}>
Adressen ändern
</Button>
</div>
);
}
return (
<div className="flex w-full flex-col gap-2 rounded-lg border border-border/60 p-3">
<Label htmlFor={inputId}>Erlaubte IP-Adressen oder Netze</Label>
<textarea
id={inputId}
rows={3}
value={text}
onChange={(event) => setText(event.target.value)}
placeholder={"203.0.113.7\n198.51.100.0/24"}
className="rounded-md border border-input bg-background px-3 py-2 font-mono text-sm"
aria-describedby={`${inputId}-hint`}
/>
<p id={`${inputId}-hint`} className="text-xs text-muted-foreground">
Eine pro Zeile. Leer lassen = von überall nutzbar.
{apiKey.lastUsedIp ? ` Zuletzt benutzt von ${apiKey.lastUsedIp}.` : ""}
</p>
<div className="flex flex-wrap gap-2">
{lastIpMissing ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setText((current) => `${current.trim()}\n${apiKey.lastUsedIp}`.trim())}
>
{apiKey.lastUsedIp} übernehmen
</Button>
) : null}
<Button
type="button"
size="sm"
disabled={mutation.isPending}
onClick={() => mutation.mutate(parseAllowlist(text))}
>
{mutation.isPending ? "Speichert…" : "Speichern"}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setText(apiKey.allowedIps.join("\n"));
setEditing(false);
}}
>
Abbrechen
</Button>
</div>
</div>
);
}
@@ -11,6 +11,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { ApiKeyAllowlist, parseAllowlist } from "./api-key-allowlist";
const DEFAULT_SCOPES: ApiKeyScope[] = ["shop-sync:read"];
@@ -36,6 +37,7 @@ export function ApiKeysTab() {
const queryClient = useQueryClient();
const [name, setName] = useState("Wabenhain-Shop");
const [expiresInDays, setExpiresInDays] = useState("");
const [allowedIps, setAllowedIps] = useState("");
const [created, setCreated] = useState<{ name: string; token: string } | null>(null);
const [copied, setCopied] = useState(false);
@@ -51,7 +53,12 @@ export function ApiKeysTab() {
setCopied(false);
void queryClient.invalidateQueries({ queryKey: ["api-keys"] });
},
onError: () => toast.error("Schlüssel konnte nicht angelegt werden."),
onError: (error) =>
toast.error(
error instanceof Error && error.message
? error.message
: "Schlüssel konnte nicht angelegt werden.",
),
});
const revokeMutation = useMutation({
@@ -66,7 +73,12 @@ export function ApiKeysTab() {
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const days = expiresInDays ? Number(expiresInDays) : undefined;
createMutation.mutate({ name: name.trim(), scopes: DEFAULT_SCOPES, expiresInDays: days });
createMutation.mutate({
name: name.trim(),
scopes: DEFAULT_SCOPES,
expiresInDays: days,
allowedIps: parseAllowlist(allowedIps),
});
}
async function copyToken() {
@@ -141,13 +153,26 @@ export function ApiKeysTab() {
onChange={(event) => setExpiresInDays(event.target.value)}
/>
</div>
<div className="flex min-w-56 flex-1 flex-col gap-1.5">
<Label htmlFor="api-key-ips">Nur von diesen IP-Adressen (empfohlen)</Label>
<Input
id="api-key-ips"
className="h-11 font-mono"
placeholder="z. B. 203.0.113.7"
value={allowedIps}
onChange={(event) => setAllowedIps(event.target.value)}
aria-describedby="api-key-ips-hint"
/>
</div>
<Button type="submit" size="touch" disabled={createMutation.isPending || !name.trim()}>
<KeyRound aria-hidden />
{createMutation.isPending ? "Wird angelegt…" : "Schlüssel anlegen"}
</Button>
</form>
<p className="-mt-3 text-xs text-muted-foreground">
Berechtigung: {API_KEY_SCOPE_LABELS["shop-sync:read"]}
<p id="api-key-ips-hint" className="-mt-3 text-xs text-muted-foreground">
Berechtigung: {API_KEY_SCOPE_LABELS["shop-sync:read"]}. IP-Adressen mit Komma trennen; leer
= von überall. Unbekannt? Leer lassen, einmal synchronisieren, dann hier „Adressen ändern“ –
die zuletzt benutzte Adresse lässt sich übernehmen.
</p>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
@@ -179,7 +204,9 @@ export function ApiKeysTab() {
Angelegt {formatDate(apiKey.createdAt)} · zuletzt benutzt{" "}
{formatDate(apiKey.lastUsedAt)}
{apiKey.expiresAt ? ` · gültig bis ${formatDate(apiKey.expiresAt)}` : ""}
{apiKey.lastUsedIp ? ` · von ${apiKey.lastUsedIp}` : ""}
</span>
{state.active ? <ApiKeyAllowlist apiKey={apiKey} /> : null}
</div>
{state.active ? (
<ConfirmDialog
+7
View File
@@ -12,6 +12,13 @@ export function createApiKey(input: CreateApiKeyInput): Promise<CreatedApiKey> {
});
}
export function updateApiKeyAllowedIps(id: string, allowedIps: string[]): Promise<ApiKey> {
return apiFetch<ApiKey>(`/api-keys/${id}`, {
method: "PATCH",
body: JSON.stringify({ allowedIps }),
});
}
export function revokeApiKey(id: string): Promise<ApiKey> {
return apiFetch<ApiKey>(`/api-keys/${id}/revoke`, { method: "POST" });
}
@@ -12,6 +12,10 @@ export interface ApiKey {
createdByUserId: string | null;
createdAt: string;
lastUsedAt: string | null;
/** Source address of the last use - handy for filling in the allowlist. */
lastUsedIp: string | null;
/** IP addresses / CIDR networks the key works from; empty = anywhere. */
allowedIps: string[];
expiresAt: string | null;
revokedAt: string | null;
}
@@ -20,6 +24,7 @@ export interface CreateApiKeyInput {
name: string;
scopes: ApiKeyScope[];
expiresInDays?: number;
allowedIps?: string[];
}
export interface CreatedApiKey {