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,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;
+4
View File
@@ -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?
@@ -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<ApiKeySummary> {
return this.apiKeys.updateAllowedIps(id, dto, actor.id);
}
@RequirePermission('api-keys:manage')
@Post(':id/revoke')
revoke(
@@ -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<ApiKeySummary[]> {
@@ -64,6 +85,15 @@ export class ApiKeysService {
dto: CreateApiKeyDto,
actingUserId: string,
): Promise<CreatedApiKey> {
// 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<ApiKeySummary> {
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<ApiKeySummary> {
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<ApiKeyPrincipal | null> {
async authenticate(
token: string,
clientIp?: string,
): Promise<ApiKeyPrincipal | null> {
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
@@ -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)`;
}
}
@@ -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[];
}
@@ -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[];
}
@@ -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);
});
});
@@ -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');
}
@@ -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();
}
+38
View File
@@ -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);
});
});