From f1dc0de3e2f2a245bd879517cb0006f19f03c95c Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 1 Oct 2026 02:41:46 +0200 Subject: [PATCH] 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) --- .../migration.sql | 3 + ApiCore-Backend/prisma/schema.prisma | 4 + .../src/api-keys/api-keys.controller.ts | 13 +- .../src/api-keys/api-keys.service.ts | 78 +++++++++++- .../src/api-keys/dto/allowed-ips.validator.ts | 17 +++ .../src/api-keys/dto/create-api-key.dto.ts | 16 +++ .../src/api-keys/dto/update-api-key.dto.ts | 17 +++ .../src/api-keys/ip-allowlist.spec.ts | 34 +++++ ApiCore-Backend/src/api-keys/ip-allowlist.ts | 66 ++++++++++ .../src/auth/guards/jwt-auth.guard.ts | 7 +- ApiCore-Backend/test/api-keys.e2e-spec.ts | 38 ++++++ .../automation/api-key-allowlist.spec.tsx | 16 +++ .../settings/automation/api-key-allowlist.tsx | 117 ++++++++++++++++++ .../settings/automation/api-keys-tab.tsx | 35 +++++- ApiCore-Frontend/src/lib/api-keys/api.ts | 7 ++ ApiCore-Frontend/src/lib/api-keys/types.ts | 5 + 16 files changed, 463 insertions(+), 10 deletions(-) create mode 100644 ApiCore-Backend/prisma/migrations/20261001120000_api_key_ip_allowlist/migration.sql create mode 100644 ApiCore-Backend/src/api-keys/dto/allowed-ips.validator.ts create mode 100644 ApiCore-Backend/src/api-keys/dto/update-api-key.dto.ts create mode 100644 ApiCore-Backend/src/api-keys/ip-allowlist.spec.ts create mode 100644 ApiCore-Backend/src/api-keys/ip-allowlist.ts create mode 100644 ApiCore-Frontend/src/app/(app)/settings/automation/api-key-allowlist.spec.tsx create mode 100644 ApiCore-Frontend/src/app/(app)/settings/automation/api-key-allowlist.tsx diff --git a/ApiCore-Backend/prisma/migrations/20261001120000_api_key_ip_allowlist/migration.sql b/ApiCore-Backend/prisma/migrations/20261001120000_api_key_ip_allowlist/migration.sql new file mode 100644 index 0000000..4515de6 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20261001120000_api_key_ip_allowlist/migration.sql @@ -0,0 +1,3 @@ +-- API keys can be limited to source addresses (IP or CIDR). +ALTER TABLE "ApiKey" ADD COLUMN "allowedIps" TEXT[] DEFAULT ARRAY[]::TEXT[]; +ALTER TABLE "ApiKey" ADD COLUMN "lastUsedIp" TEXT; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index 0d544a7..8ec0ce7 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -147,6 +147,10 @@ model ApiKey { createdByUser User? @relation(fields: [createdByUserId], references: [id]) createdAt DateTime @default(now()) lastUsedAt DateTime? + // Absender-IP der letzten Nutzung - hilft beim Einrichten der Allowlist. + lastUsedIp String? + // Nur von diesen Adressen/Netzen (IP oder CIDR) gueltig; leer = ueberall. + allowedIps String[] @default([]) expiresAt DateTime? revokedAt DateTime? diff --git a/ApiCore-Backend/src/api-keys/api-keys.controller.ts b/ApiCore-Backend/src/api-keys/api-keys.controller.ts index aa544e5..1d300ee 100644 --- a/ApiCore-Backend/src/api-keys/api-keys.controller.ts +++ b/ApiCore-Backend/src/api-keys/api-keys.controller.ts @@ -1,9 +1,10 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { RequirePermission } from '../auth/decorators/require-permission.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import type { AuthenticatedUser } from '../auth/types/authenticated-user'; import { CreateApiKeyDto } from './dto/create-api-key.dto'; +import { UpdateApiKeyDto } from './dto/update-api-key.dto'; import { ApiKeySummary, ApiKeysService, @@ -31,6 +32,16 @@ export class ApiKeysController { return this.apiKeys.create(dto, actor.id); } + @RequirePermission('api-keys:manage') + @Patch(':id') + updateAllowedIps( + @Param('id') id: string, + @Body() dto: UpdateApiKeyDto, + @CurrentUser() actor: AuthenticatedUser, + ): Promise { + return this.apiKeys.updateAllowedIps(id, dto, actor.id); + } + @RequirePermission('api-keys:manage') @Post(':id/revoke') revoke( diff --git a/ApiCore-Backend/src/api-keys/api-keys.service.ts b/ApiCore-Backend/src/api-keys/api-keys.service.ts index bb978cb..0da0256 100644 --- a/ApiCore-Backend/src/api-keys/api-keys.service.ts +++ b/ApiCore-Backend/src/api-keys/api-keys.service.ts @@ -1,10 +1,28 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; import { createHash, randomBytes, randomInt } from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { ApiKey } from '../../generated/prisma'; import { CreateApiKeyDto } from './dto/create-api-key.dto'; import { ApiKeyScope, isApiKeyScope } from './api-key-scopes'; +import { UpdateApiKeyDto } from './dto/update-api-key.dto'; +import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; + +function normalizeAllowlist(entries: readonly string[] | undefined): string[] { + return [ + ...new Set( + (entries ?? []) + .map(normalizeAllowlistEntry) + .filter((entry): entry is string => entry !== null), + ), + ]; +} // ack_<8 lowercase chars>_<32 random bytes, base64url> const TOKEN_PATTERN = /^ack_[a-z0-9]{8}_[A-Za-z0-9_-]{43}$/; @@ -48,9 +66,12 @@ function toSummary(apiKey: ApiKey): ApiKeySummary { @Injectable() export class ApiKeysService { + private readonly logger = new Logger(ApiKeysService.name); + constructor( private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, + private readonly permissionResolver: PermissionResolverService, ) {} async findAll(): Promise { @@ -64,6 +85,15 @@ export class ApiKeysService { dto: CreateApiKeyDto, actingUserId: string, ): Promise { + // Nobody hands a machine more than they may do themselves. + const callerPermissions = + await this.permissionResolver.getPermissions(actingUserId); + const missing = dto.scopes.filter((scope) => !callerPermissions.has(scope)); + if (missing.length > 0) { + throw new ForbiddenException( + `Diese Berechtigung haben Sie selbst nicht: ${missing.join(', ')}`, + ); + } const prefix = randomPrefix(); // 32 random bytes: a plain hash of it is safe to store without a slow // KDF - there is nothing guessable to brute-force. @@ -79,6 +109,7 @@ export class ApiKeysService { prefix, keyHash: hashToken(token), scopes: [...new Set(dto.scopes)], + allowedIps: normalizeAllowlist(dto.allowedIps), expiresAt, createdByUserId: actingUserId, }, @@ -94,6 +125,31 @@ export class ApiKeysService { return { apiKey: summary, token }; } + /** Changes where a key may be used from, without issuing a new one. */ + async updateAllowedIps( + id: string, + dto: UpdateApiKeyDto, + actingUserId: string, + ): Promise { + const before = await this.prisma.apiKey.findUnique({ where: { id } }); + if (!before) { + throw new NotFoundException('API-Schlüssel nicht gefunden'); + } + const updated = await this.prisma.apiKey.update({ + where: { id }, + data: { allowedIps: normalizeAllowlist(dto.allowedIps) }, + }); + await this.auditLog.record({ + entityType: 'ApiKey', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: toSummary(before), + afterData: toSummary(updated), + }); + return toSummary(updated); + } + async revoke(id: string, actingUserId: string): Promise { const before = await this.prisma.apiKey.findUnique({ where: { id } }); if (!before) { @@ -121,7 +177,10 @@ export class ApiKeysService { * Resolves a presented key to what it may do, or null for anything that * must not pass: malformed, unknown, revoked or expired. */ - async authenticate(token: string): Promise { + async authenticate( + token: string, + clientIp?: string, + ): Promise { if (!TOKEN_PATTERN.test(token)) { return null; } @@ -136,13 +195,24 @@ export class ApiKeysService { ) { return null; } + if (!isIpAllowed(clientIp, apiKey.allowedIps)) { + // The key itself is right: say so, so the admin finds the cause - a + // thief learns nothing new, they already hold the key. + this.logger.warn( + `API key ${apiKey.prefix} used from ${clientIp ?? 'unknown'}, not in its allowlist`, + ); + throw new ForbiddenException( + 'API-Schlüssel ist für diese IP-Adresse nicht freigegeben', + ); + } if ( !apiKey.lastUsedAt || - now.getTime() - apiKey.lastUsedAt.getTime() > LAST_USED_RESOLUTION_MS + now.getTime() - apiKey.lastUsedAt.getTime() > LAST_USED_RESOLUTION_MS || + apiKey.lastUsedIp !== (clientIp ?? null) ) { await this.prisma.apiKey.update({ where: { id: apiKey.id }, - data: { lastUsedAt: now }, + data: { lastUsedAt: now, lastUsedIp: clientIp ?? null }, }); } // Intersect with today's allowlist: narrowing it later also narrows diff --git a/ApiCore-Backend/src/api-keys/dto/allowed-ips.validator.ts b/ApiCore-Backend/src/api-keys/dto/allowed-ips.validator.ts new file mode 100644 index 0000000..9aefc07 --- /dev/null +++ b/ApiCore-Backend/src/api-keys/dto/allowed-ips.validator.ts @@ -0,0 +1,17 @@ +import { + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; +import { normalizeAllowlistEntry } from '../ip-allowlist'; + +@ValidatorConstraint({ name: 'allowedIp' }) +export class AllowedIpConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + return typeof value === 'string' && normalizeAllowlistEntry(value) !== null; + } + + defaultMessage(args: ValidationArguments): string { + return `${String(args.value)} ist keine IP-Adresse und kein Netz (z. B. 203.0.113.7 oder 203.0.113.0/24)`; + } +} diff --git a/ApiCore-Backend/src/api-keys/dto/create-api-key.dto.ts b/ApiCore-Backend/src/api-keys/dto/create-api-key.dto.ts index 86a9a7e..4dcb8f0 100644 --- a/ApiCore-Backend/src/api-keys/dto/create-api-key.dto.ts +++ b/ApiCore-Backend/src/api-keys/dto/create-api-key.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { + ArrayMaxSize, ArrayMinSize, ArrayUnique, IsArray, @@ -11,8 +12,11 @@ import { MaxLength, Min, MinLength, + Validate, } from 'class-validator'; import { API_KEY_SCOPES } from '../api-key-scopes'; +import { MAX_ALLOWED_IPS } from '../ip-allowlist'; +import { AllowedIpConstraint } from './allowed-ips.validator'; export class CreateApiKeyDto { @ApiProperty({ @@ -39,4 +43,16 @@ export class CreateApiKeyDto { @Min(1) @Max(730) expiresInDays?: number; + + @ApiPropertyOptional({ + description: + 'Erlaubte Absender (IP oder CIDR), z. B. die Adresse des Wabenhain-Servers', + type: [String], + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(MAX_ALLOWED_IPS) + @IsString({ each: true }) + @Validate(AllowedIpConstraint, { each: true }) + allowedIps?: string[]; } diff --git a/ApiCore-Backend/src/api-keys/dto/update-api-key.dto.ts b/ApiCore-Backend/src/api-keys/dto/update-api-key.dto.ts new file mode 100644 index 0000000..8afdfae --- /dev/null +++ b/ApiCore-Backend/src/api-keys/dto/update-api-key.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMaxSize, IsArray, IsString, Validate } from 'class-validator'; +import { MAX_ALLOWED_IPS } from '../ip-allowlist'; +import { AllowedIpConstraint } from './allowed-ips.validator'; + +export class UpdateApiKeyDto { + @ApiProperty({ + description: + 'Erlaubte Absender (IP oder CIDR); leere Liste = von überall nutzbar', + type: [String], + }) + @IsArray() + @ArrayMaxSize(MAX_ALLOWED_IPS) + @IsString({ each: true }) + @Validate(AllowedIpConstraint, { each: true }) + allowedIps!: string[]; +} diff --git a/ApiCore-Backend/src/api-keys/ip-allowlist.spec.ts b/ApiCore-Backend/src/api-keys/ip-allowlist.spec.ts new file mode 100644 index 0000000..9e7c827 --- /dev/null +++ b/ApiCore-Backend/src/api-keys/ip-allowlist.spec.ts @@ -0,0 +1,34 @@ +import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist'; + +describe('ip-allowlist', () => { + it('normalizes addresses and networks', () => { + expect(normalizeAllowlistEntry(' 203.0.113.7 ')).toBe('203.0.113.7'); + expect(normalizeAllowlistEntry('203.0.113.0/24')).toBe('203.0.113.0/24'); + expect(normalizeAllowlistEntry('2001:db8::/32')).toBe('2001:db8::/32'); + expect(normalizeAllowlistEntry('::ffff:203.0.113.7')).toBe('203.0.113.7'); + }); + + it('rejects anything else', () => { + for (const entry of [ + '', + 'example.com', + '203.0.113.0/33', + '1.2.3.4/x', + '1.2.3.4/8/1', + ]) { + expect(normalizeAllowlistEntry(entry)).toBeNull(); + } + }); + + it('allows every address when the list is empty', () => { + expect(isIpAllowed('198.51.100.1', [])).toBe(true); + }); + + it('allows only listed addresses and networks', () => { + const list = ['203.0.113.7', '198.51.100.0/24']; + expect(isIpAllowed('203.0.113.7', list)).toBe(true); + expect(isIpAllowed('::ffff:198.51.100.42', list)).toBe(true); + expect(isIpAllowed('203.0.113.8', list)).toBe(false); + expect(isIpAllowed(undefined, list)).toBe(false); + }); +}); diff --git a/ApiCore-Backend/src/api-keys/ip-allowlist.ts b/ApiCore-Backend/src/api-keys/ip-allowlist.ts new file mode 100644 index 0000000..07f349f --- /dev/null +++ b/ApiCore-Backend/src/api-keys/ip-allowlist.ts @@ -0,0 +1,66 @@ +import * as net from 'node:net'; + +/** Max entries per key - an allowlist is a short list of servers. */ +export const MAX_ALLOWED_IPS = 20; + +function unmapIpv4(ip: string): string { + const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip); + return mapped ? mapped[1] : ip; +} + +/** + * "203.0.113.7", "203.0.113.0/24", "2001:db8::/32" in canonical form, or + * null if the entry is neither an address nor a network. + */ +export function normalizeAllowlistEntry(entry: string): string | null { + const trimmed = entry.trim(); + const [address, prefix, ...rest] = trimmed.split('/'); + if (rest.length > 0 || !address) { + return null; + } + const ip = unmapIpv4(address); + const version = net.isIP(ip); + if (version === 0) { + return null; + } + if (prefix === undefined) { + return ip; + } + if (!/^\d{1,3}$/.test(prefix)) { + return null; + } + const bits = Number(prefix); + if (bits > (version === 4 ? 32 : 128)) { + return null; + } + return `${ip}/${bits}`; +} + +/** An empty list allows every address. */ +export function isIpAllowed( + clientIp: string | undefined, + allowedIps: readonly string[], +): boolean { + if (allowedIps.length === 0) { + return true; + } + if (!clientIp) { + return false; + } + const ip = unmapIpv4(clientIp); + const version = net.isIP(ip); + if (version === 0) { + return false; + } + const list = new net.BlockList(); + for (const entry of allowedIps) { + const [address, prefix] = entry.split('/'); + const family = net.isIP(address) === 6 ? 'ipv6' : 'ipv4'; + if (prefix === undefined) { + list.addAddress(address, family); + } else { + list.addSubnet(address, Number(prefix), family); + } + } + return list.check(ip, version === 6 ? 'ipv6' : 'ipv4'); +} diff --git a/ApiCore-Backend/src/auth/guards/jwt-auth.guard.ts b/ApiCore-Backend/src/auth/guards/jwt-auth.guard.ts index 061a6d2..788e0d1 100644 --- a/ApiCore-Backend/src/auth/guards/jwt-auth.guard.ts +++ b/ApiCore-Backend/src/auth/guards/jwt-auth.guard.ts @@ -16,6 +16,7 @@ import { interface RequestWithUser { headers: { authorization?: string }; + ip?: string; user?: AuthenticatedUser; apiKey?: ApiKeyPrincipal; } @@ -55,7 +56,11 @@ export class JwtAuthGuard extends AuthGuard('jwt') { // PermissionsGuard checks its scopes instead of a role. const apiKeyToken = extractApiKeyToken(request.headers.authorization); if (apiKeyToken !== null) { - const principal = await this.apiKeys.authenticate(apiKeyToken); + // request.ip honours TRUST_PROXY (see app.config.ts). + const principal = await this.apiKeys.authenticate( + apiKeyToken, + request.ip, + ); if (!principal) { throw new UnauthorizedException(); } diff --git a/ApiCore-Backend/test/api-keys.e2e-spec.ts b/ApiCore-Backend/test/api-keys.e2e-spec.ts index cfacfd2..cc14046 100644 --- a/ApiCore-Backend/test/api-keys.e2e-spec.ts +++ b/ApiCore-Backend/test/api-keys.e2e-spec.ts @@ -190,4 +190,42 @@ describe('API keys (e2e)', () => { .send({ name: 'x', scopes: ['shop-sync:read'] }) .expect(403); }); + + it('works only from allowed addresses and records where it was used', async () => { + const elsewhere = ( + await createKey({ + name: `Elsewhere ${suffix}`, + scopes: ['shop-sync:read'], + allowedIps: ['203.0.113.7'], + }).expect(201) + ).body as CreatedKeyBody; + await request(server()) + .get('/api/v1/shop-sync') + .set('Authorization', `Bearer ${elsewhere.token}`) + .expect(403); + + // Allow loopback (where this test runs) afterwards, without a new key. + await request(server()) + .patch(`/api/v1/api-keys/${elsewhere.apiKey.id}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ allowedIps: ['127.0.0.0/8', '::1'] }) + .expect(200); + await request(server()) + .get('/api/v1/shop-sync') + .set('Authorization', `Bearer ${elsewhere.token}`) + .expect(200); + + const stored = await prisma.apiKey.findUniqueOrThrow({ + where: { id: elsewhere.apiKey.id }, + }); + expect(stored.lastUsedIp).toMatch(/127\.0\.0\.1|::1/); + }); + + it('refuses an allowlist entry that is not an address or network', async () => { + await createKey({ + name: `Bad allowlist ${suffix}`, + scopes: ['shop-sync:read'], + allowedIps: ['example.com'], + }).expect(400); + }); }); diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/api-key-allowlist.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/api-key-allowlist.spec.tsx new file mode 100644 index 0000000..dd6314f --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/api-key-allowlist.spec.tsx @@ -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([]); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/api-key-allowlist.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/api-key-allowlist.tsx new file mode 100644 index 0000000..5b21782 --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/api-key-allowlist.tsx @@ -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 ( +
+ {apiKey.allowedIps.length > 0 ? ( + + + Nur von: {apiKey.allowedIps.join(", ")} + + ) : ( + + + Von jeder Adresse nutzbar – besser auf den Server von Wabenhain beschränken. + + )} + +
+ ); + } + + return ( +
+ +