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:
1 parent
9ae1d96a32
commit
f1dc0de3e2
16 files changed
+463
-10
No files matched your search
@@ -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');
|
||||
}
|
||||
Reference in new issue
Block a user