Compare commits

..
6 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 e28cc8e0f3 fix(api-keys): Fehlversuche je Client-Netz zählen (IPv6 /64)
Wie der Throttler seit 886b4e7: die Sperre nach abgelehnten Schlüsseln
zählt über rateLimitSubject, damit wechselnde Adressen im eigenen
IPv6-/64 das Budget nicht zurücksetzen. Im Log steht weiter die Adresse.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:47:05 +02:00
Nick Szaunig e357e8be9a Merge remote-tracking branch 'origin/main' into feat/erp-inventar-export 2026-10-12 11:44:31 +02:00
Nick SzaunigandClaude Opus 5.5 f5b1964223 fix(k8s): Ingress-Vorlagen auf Traefik statt nginx
Frontend Image / build-and-push (push) Canceled after 0s
Backend Image / build-and-push (push) Successful in 3m48s
Die cloudflare-only-Annotation ist eine Traefik-Annotation und war mit
ingressClassName nginx wirkungslos. Produktion laeuft auf RKE2 mit Traefik
(Ingresses apicore-backend/-frontend), die Vorlagen nennen jetzt dieselbe
Klasse.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:26:37 +02:00
Nick SzaunigandClaude Opus 5.5 e53e4fb1cf fix(k8s): Traefik-Middleware cloudflare-only an Ingress-Manifesten
Frontend Image / build-and-push (push) Canceled after 0s
Backend Image / build-and-push (push) Canceled after 0s
Die live gesetzte Annotation traefik.ingress.kubernetes.io/router.middlewares
fehlte in den Manifesten (Backend und Frontend). Sie schuetzt vor
Direktzugriff am Origin vorbei, nicht vor fremden Cloudflare-Zonen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:23:46 +02:00
Nick SzaunigandClaude Opus 5.5 d9670dcda5 fix: TRUST_PROXY-Kommentar aktualisieren und Modus beim Start loggen
Die Kommentare beschrieben noch einen einzelnen Reverse-Proxy-Hop. Sie
beschreiben jetzt die Cloudflare/Traefik-Liste. Beim Start wird einmal
auf info-Level geloggt, ob Hop-Zahl, Liste oder der Default aktiv ist
(nur Anzahl, keine Adressen), damit eine fehlende Produktionseinstellung
auffaellt. Das Standardverhalten bleibt unveraendert.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:23:41 +02:00
Nick SzaunigandClaude Opus 5.5 886b4e75e1 fix: Rate-Limit pro IPv6-/64 statt pro Einzeladresse
Der Standard-ThrottlerGuard trackt die volle req.ip. Ein IPv6-Client
kann innerhalb seines /64 beliebig Adressen rotieren und so die
Login-/Refresh-/SSO-Limits umgehen. Neuer ClientIpThrottlerGuard
liefert IPv4 unveraendert, IPv4-mapped als IPv4 und IPv6 als /64-Praefix.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:18:56 +02:00
13 changed files with 284 additions and 17 deletions

No files matched your search

+1 -1
View File
@@ -143,7 +143,7 @@ Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung un
| Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt | | Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt |
| Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) | | Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) |
| Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten | | Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten |
| Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (`shop-sync:read`, `shop-sync:write`, `erp-inventory:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben; nach 20 abgelehnten Schlüsseln in 15 min je IP `429`; Logs nur mit Schlüssel-Präfix | | Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (`shop-sync:read`, `shop-sync:write`, `erp-inventory:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben; nach 20 abgelehnten Schlüsseln in 15 min je IP (IPv6: je /64) `429`; Logs nur mit Schlüssel-Präfix |
| Ausgehende Aufrufe | Webhooks: Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, 5 s Timeout, Signatur über Zeitstempel+ID+Body | | Ausgehende Aufrufe | Webhooks: Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, 5 s Timeout, Signatur über Zeitstempel+ID+Body |
| Transport/Antworten | `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies | | Transport/Antworten | `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies |
| Rate-Limits | Throttler vor der Authentifizierung (100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` | | Rate-Limits | Throttler vor der Authentifizierung (100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` |
@@ -159,6 +159,21 @@ describe('ApiKeysService.authenticate', () => {
scopes: ['erp-inventory:read'], scopes: ['erp-inventory:read'],
}); });
}); });
it('zählt IPv6-Adressen aus demselben /64 gemeinsam (wie der Throttler)', async () => {
// Wechselnde Adressen im eigenen /64 umgehen die Sperre nicht.
for (let i = 1; i <= API_KEY_FAILURE_LIMIT.maxFailures; i++) {
await service.authenticate(TOKEN, `2001:db8:1:2::${i.toString(16)}`);
}
await expect(
service.authenticate(TOKEN, '2001:db8:1:2::ffff'),
).rejects.toMatchObject({ status: 429 });
// Ein anderes /64 ist frei.
prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey());
await expect(
service.authenticate(TOKEN, '2001:db8:1:3::1'),
).resolves.toMatchObject({ id: 'key-1' });
});
}); });
describe('extractApiKeyToken', () => { describe('extractApiKeyToken', () => {
@@ -16,6 +16,7 @@ import { UpdateApiKeyDto } from './dto/update-api-key.dto';
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist'; import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { FailedAttemptLimiter } from './failed-attempt-limiter'; import { FailedAttemptLimiter } from './failed-attempt-limiter';
import { rateLimitSubject } from '../common/throttler/rate-limit-subject';
function normalizeAllowlist(entries: readonly string[] | undefined): string[] { function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
return [ return [
@@ -211,7 +212,10 @@ export class ApiKeysService {
clientIp?: string, clientIp?: string,
): Promise<ApiKeyPrincipal | null> { ): Promise<ApiKeyPrincipal | null> {
const source = clientIp ?? 'unknown'; const source = clientIp ?? 'unknown';
if (this.failures.isBlocked(source)) { // Counted per client network like the throttler (IPv6 /64), so rotating
// addresses inside one's own prefix does not reset the budget.
const subject = clientIp ? rateLimitSubject(clientIp) : 'unknown';
if (this.failures.isBlocked(subject)) {
// Same body shape as the built-in 4xx exceptions (JSON object, not a // Same body shape as the built-in 4xx exceptions (JSON object, not a
// bare string), so clients can read `message` uniformly. // bare string), so clients can read `message` uniformly.
throw new HttpException( throw new HttpException(
@@ -236,9 +240,9 @@ export class ApiKeysService {
this.logger.warn( this.logger.warn(
`API key rejected (${result.reason}): ${prefix} from ${source}`, `API key rejected (${result.reason}): ${prefix} from ${source}`,
); );
if (this.failures.recordFailure(source)) { if (this.failures.recordFailure(subject)) {
this.logger.warn( this.logger.warn(
`API key attempts from ${source} blocked for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes after ${API_KEY_FAILURE_LIMIT.maxFailures} failures`, `API key attempts from ${subject} blocked for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes after ${API_KEY_FAILURE_LIMIT.maxFailures} failures`,
); );
} }
return null; return null;
+67
View File
@@ -0,0 +1,67 @@
import { Logger } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
import {
configureApp,
describeTrustProxy,
trustProxySetting,
} from './app.config';
describe('trustProxySetting', () => {
const original = process.env.TRUST_PROXY;
afterEach(() => {
if (original === undefined) delete process.env.TRUST_PROXY;
else process.env.TRUST_PROXY = original;
});
it('defaults to one hop when unset or blank', () => {
delete process.env.TRUST_PROXY;
expect(trustProxySetting()).toBe(1);
process.env.TRUST_PROXY = ' ';
expect(trustProxySetting()).toBe(1);
});
it('parses a number as hop count and anything else as a list', () => {
process.env.TRUST_PROXY = '2';
expect(trustProxySetting()).toBe(2);
process.env.TRUST_PROXY = 'loopback, 10.42.0.0/16';
expect(trustProxySetting()).toBe('loopback, 10.42.0.0/16');
});
});
describe('describeTrustProxy', () => {
it('reports the hop count mode and the default fallback', () => {
expect(describeTrustProxy(1, false)).toBe(
'TRUST_PROXY not set, using default of 1 trusted hop',
);
expect(describeTrustProxy(2, true)).toBe('TRUST_PROXY: 2 trusted hop(s)');
});
it('reports the list mode with entry count but not the entries', () => {
const text = describeTrustProxy(
'loopback, 10.42.0.0/16, 2400:cb00::/32',
true,
);
expect(text).toBe('TRUST_PROXY: list of 3 trusted proxy range(s)');
});
});
describe('configureApp trust proxy logging', () => {
it('logs the active mode once at startup', () => {
const log = jest.spyOn(Logger.prototype, 'log').mockImplementation();
const app = {
set: jest.fn(),
useBodyParser: jest.fn(),
use: jest.fn(),
useGlobalFilters: jest.fn(),
enableCors: jest.fn(),
setGlobalPrefix: jest.fn(),
enableVersioning: jest.fn(),
useGlobalPipes: jest.fn(),
};
configureApp(app as unknown as NestExpressApplication);
expect(
log.mock.calls.filter(([m]) => String(m).startsWith('TRUST_PROXY')),
).toHaveLength(1);
log.mockRestore();
});
});
+35 -8
View File
@@ -1,4 +1,9 @@
import { RequestMethod, ValidationPipe, VersioningType } from '@nestjs/common'; import {
Logger,
RequestMethod,
ValidationPipe,
VersioningType,
} from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express'; import { NestExpressApplication } from '@nestjs/platform-express';
import helmet from 'helmet'; import helmet from 'helmet';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
@@ -7,11 +12,15 @@ import { requestLoggerMiddleware } from './common/middleware/request-logger.midd
import { noStoreMiddleware } from './common/middleware/no-store.middleware'; import { noStoreMiddleware } from './common/middleware/no-store.middleware';
/** /**
* How many reverse-proxy hops to trust for X-Forwarded-For. It decides the * Which proxies to trust for X-Forwarded-For. It decides the client IP behind
* client IP behind every rate limit and API-key IP allowlist: too few and * every rate limit and API-key IP allowlist: trust too little and all users
* all users share the proxy's IP, too many and a client can forge its own. * share the proxy's IP, trust too much and a client can forge its own.
* A hop count ("1", the single K8s ingress) or Express' list syntax *
* ("loopback, 10.0.0.0/8"). * Production runs client -> Cloudflare -> Traefik -> backend, so the hop
* count is not stable; TRUST_PROXY is an Express list there ("loopback,
* 10.42.0.0/16, <Cloudflare IPv4+IPv6 ranges>", see
* deploy/k8s/backend/configmap.example.yaml). A plain number ("1") is a hop
* count and stays the default when the variable is unset.
*/ */
export function trustProxySetting(): number | string { export function trustProxySetting(): number | string {
const raw = process.env.TRUST_PROXY?.trim(); const raw = process.env.TRUST_PROXY?.trim();
@@ -21,11 +30,29 @@ export function trustProxySetting(): number | string {
return /^\d+$/.test(raw) ? Number(raw) : raw; return /^\d+$/.test(raw) ? Number(raw) : raw;
} }
/** Startup log line for the active mode; never includes the addresses. */
export function describeTrustProxy(
setting: number | string,
isExplicit: boolean,
): string {
if (typeof setting === 'number') {
return isExplicit
? `TRUST_PROXY: ${setting} trusted hop(s)`
: `TRUST_PROXY not set, using default of ${setting} trusted hop`;
}
const entries = setting.split(',').filter((e) => e.trim()).length;
return `TRUST_PROXY: list of ${entries} trusted proxy range(s)`;
}
export function configureApp(app: NestExpressApplication): void { export function configureApp(app: NestExpressApplication): void {
// Trust the single reverse-proxy hop (K8s ingress) so req.ip reflects the // Trust the configured proxies (Traefik, Cloudflare) so req.ip reflects the
// real client IP from X-Forwarded-For — required for per-client throttling // real client IP from X-Forwarded-For — required for per-client throttling
// to work correctly rather than rate-limiting all traffic together. // to work correctly rather than rate-limiting all traffic together.
app.set('trust proxy', trustProxySetting()); const trustProxy = trustProxySetting();
app.set('trust proxy', trustProxy);
new Logger('Bootstrap').log(
describeTrustProxy(trustProxy, !!process.env.TRUST_PROXY?.trim()),
);
// No ETags: they let caches and proxies revalidate (and keep) personal // No ETags: they let caches and proxies revalidate (and keep) personal
// responses that are meant to be no-store. // responses that are meant to be no-store.
app.set('etag', false); app.set('etag', false);
+3 -2
View File
@@ -1,7 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerModule } from '@nestjs/throttler';
import { ClientIpThrottlerGuard } from './common/throttler/client-ip-throttler.guard';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
@@ -114,7 +115,7 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
providers: [ providers: [
// Throttle first: a request with a wrong token or API key must count // Throttle first: a request with a wrong token or API key must count
// against the limit too, before any database lookup is spent on it. // against the limit too, before any database lookup is spent on it.
{ provide: APP_GUARD, useClass: ThrottlerGuard }, { provide: APP_GUARD, useClass: ClientIpThrottlerGuard },
{ provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard }, { provide: APP_GUARD, useClass: PermissionsGuard },
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor }, { provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
@@ -0,0 +1,26 @@
import { ClientIpThrottlerGuard } from './client-ip-throttler.guard';
describe('ClientIpThrottlerGuard.getTracker', () => {
// getTracker is protected and needs none of the guard's dependencies.
const guard = Object.create(
ClientIpThrottlerGuard.prototype,
) as ClientIpThrottlerGuard & {
getTracker(req: Record<string, any>): Promise<string>;
};
it('tracks IPv6 clients by their /64 prefix', async () => {
await expect(guard.getTracker({ ip: '2001:db8:1:2::9' })).resolves.toBe(
'2001:db8:1:2::/64',
);
});
it('tracks IPv4 clients by address', async () => {
await expect(guard.getTracker({ ip: '203.0.113.7' })).resolves.toBe(
'203.0.113.7',
);
});
it('falls back to a stable key when req.ip is missing', async () => {
await expect(guard.getTracker({})).resolves.toBe('unknown');
});
});
@@ -0,0 +1,14 @@
import { Injectable } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';
import { rateLimitSubject } from './rate-limit-subject';
/** ThrottlerGuard that counts per client network (IPv6 /64), not per address. */
@Injectable()
export class ClientIpThrottlerGuard extends ThrottlerGuard {
protected getTracker(req: Record<string, any>): Promise<string> {
const ip: unknown = req.ip;
return Promise.resolve(
typeof ip === 'string' && ip ? rateLimitSubject(ip) : 'unknown',
);
}
}
@@ -0,0 +1,56 @@
import { rateLimitSubject } from './rate-limit-subject';
describe('rateLimitSubject', () => {
it('returns IPv4 unchanged', () => {
expect(rateLimitSubject('203.0.113.7')).toBe('203.0.113.7');
});
it('unwraps IPv4-mapped IPv6 addresses', () => {
expect(rateLimitSubject('::ffff:203.0.113.7')).toBe('203.0.113.7');
expect(rateLimitSubject('::FFFF:203.0.113.7')).toBe('203.0.113.7');
});
it('reduces a compressed IPv6 address to its /64 prefix', () => {
expect(rateLimitSubject('2001:db8:1:2::5')).toBe('2001:db8:1:2::/64');
});
it('reduces a full-form IPv6 address to its /64 prefix', () => {
expect(rateLimitSubject('2001:0db8:0001:0002:aaaa:bbbb:cccc:dddd')).toBe(
'2001:db8:1:2::/64',
);
});
it('normalises uppercase', () => {
expect(rateLimitSubject('2001:DB8:1:2::ABCD')).toBe('2001:db8:1:2::/64');
});
it('handles :: at the start and for the unspecified address', () => {
expect(rateLimitSubject('::1')).toBe('0:0:0:0::/64');
expect(rateLimitSubject('::')).toBe('0:0:0:0::/64');
});
it('handles :: that swallows part of the prefix', () => {
expect(rateLimitSubject('2001:db8::1')).toBe('2001:db8:0:0::/64');
});
it('maps different interface identifiers in one /64 to the same key', () => {
expect(rateLimitSubject('2001:db8:1:2:1::1')).toBe(
rateLimitSubject('2001:db8:1:2:ffff:eeee:dddd:cccc'),
);
});
it('maps different /64 networks to different keys', () => {
expect(rateLimitSubject('2001:db8:1:2::1')).not.toBe(
rateLimitSubject('2001:db8:1:3::1'),
);
});
it('ignores an IPv6 zone id', () => {
expect(rateLimitSubject('fe80::1%eth0')).toBe('fe80:0:0:0::/64');
});
it('returns unparseable input unchanged', () => {
expect(rateLimitSubject('not-an-ip')).toBe('not-an-ip');
expect(rateLimitSubject('')).toBe('');
});
});
@@ -0,0 +1,47 @@
import { isIPv4, isIPv6 } from 'node:net';
const MAPPED_V4_PREFIX = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i;
const PREFIX_GROUPS = 4; // /64 = first four 16-bit groups
/** Expands an IPv6 address (no zone id) into its eight 16-bit groups. */
function expandIPv6(address: string): number[] {
let text = address;
// Embedded IPv4 tail (e.g. 64:ff9b::1.2.3.4) becomes two hex groups.
const tail = /(\d{1,3}(?:\.\d{1,3}){3})$/.exec(text);
if (tail) {
const [a, b, c, d] = tail[1].split('.').map(Number);
const hi = ((a << 8) | b).toString(16);
const lo = ((c << 8) | d).toString(16);
text = `${text.slice(0, tail.index)}${hi}:${lo}`;
}
const [head, rest] = text.split('::');
const headGroups = head ? head.split(':') : [];
const restGroups = rest === undefined ? [] : rest ? rest.split(':') : [];
const missing = 8 - headGroups.length - restGroups.length;
const groups =
rest === undefined
? headGroups
: [...headGroups, ...Array<string>(missing).fill('0'), ...restGroups];
return groups.map((g) => parseInt(g, 16));
}
/**
* Rate-limit key for a client IP. IPv4 stays as is, IPv4-mapped IPv6 becomes
* plain IPv4, and IPv6 collapses to its /64 prefix: a single subscriber
* controls a whole /64, so keying on the full address would let it rotate
* addresses to dodge every limit. Unparseable input is returned unchanged.
*/
export function rateLimitSubject(ip: string): string {
const mapped = MAPPED_V4_PREFIX.exec(ip);
if (mapped && isIPv4(mapped[1])) {
return mapped[1];
}
const address = ip.split('%')[0];
if (!isIPv6(address)) {
return ip;
}
const prefix = expandIPv6(address)
.slice(0, PREFIX_GROUPS)
.map((g) => g.toString(16));
return `${prefix.join(':')}::/64`;
}
+6 -1
View File
@@ -6,8 +6,13 @@ metadata:
# Requires cert-manager with a ClusterIssuer named "letsencrypt-prod" # Requires cert-manager with a ClusterIssuer named "letsencrypt-prod"
# (or swap for whatever issuer/cert mechanism the cluster already uses). # (or swap for whatever issuer/cert mechanism the cluster already uses).
cert-manager.io/cluster-issuer: letsencrypt-prod cert-manager.io/cluster-issuer: letsencrypt-prod
# Nur Traffic ueber Cloudflare zulassen. Die Middleware liegt im Repo
# rke2-cluster (security/cloudflare-only-middlewares.yaml). Sie schuetzt vor
# Direktzugriff am Origin vorbei, nicht vor fremden Cloudflare-Zonen.
traefik.ingress.kubernetes.io/router.middlewares: apicore-cloudflare-only@kubernetescrd
spec: spec:
ingressClassName: nginx # Der Cluster (RKE2) nutzt Traefik; nur damit wirkt die Middleware-Annotation oben.
ingressClassName: traefik
tls: tls:
- hosts: - hosts:
- api.apicore.example.com - api.apicore.example.com
+6 -1
View File
@@ -6,8 +6,13 @@ metadata:
# Requires cert-manager with a ClusterIssuer named "letsencrypt-prod" # Requires cert-manager with a ClusterIssuer named "letsencrypt-prod"
# (or swap for whatever issuer/cert mechanism the cluster already uses). # (or swap for whatever issuer/cert mechanism the cluster already uses).
cert-manager.io/cluster-issuer: letsencrypt-prod cert-manager.io/cluster-issuer: letsencrypt-prod
# Nur Traffic ueber Cloudflare zulassen. Die Middleware liegt im Repo
# rke2-cluster (security/cloudflare-only-middlewares.yaml). Sie schuetzt vor
# Direktzugriff am Origin vorbei, nicht vor fremden Cloudflare-Zonen.
traefik.ingress.kubernetes.io/router.middlewares: apicore-cloudflare-only@kubernetescrd
spec: spec:
ingressClassName: nginx # Der Cluster (RKE2) nutzt Traefik; nur damit wirkt die Middleware-Annotation oben.
ingressClassName: traefik
tls: tls:
- hosts: - hosts:
- apicore.example.com - apicore.example.com
+1 -1
View File
@@ -16,7 +16,7 @@ Der ERPNext-Importer wird genau nach diesem Dokument gebaut. Änderungen am Vert
| Speicherung | ApiCore speichert nur den SHA-256-Hash. Gesucht wird über den Hash (eindeutiger Index), nie durch Vergleich des Geheimnisses. Der Schlüssel selbst wird genau einmal beim Anlegen angezeigt. | | Speicherung | ApiCore speichert nur den SHA-256-Hash. Gesucht wird über den Hash (eindeutiger Index), nie durch Vergleich des Geheimnisses. Der Schlüssel selbst wird genau einmal beim Anlegen angezeigt. |
| Abschalten | Jederzeit widerrufbar (wirkt sofort), optional mit Ablaufdatum. | | Abschalten | Jederzeit widerrufbar (wirkt sofort), optional mit Ablaufdatum. |
| Absender | Optional nur von bestimmten IP-Adressen/Netzen (Allowlist am Schlüssel). | | Absender | Optional nur von bestimmten IP-Adressen/Netzen (Allowlist am Schlüssel). |
| Fehlversuche | Nach 20 abgelehnten Schlüsseln (fehlerhaft, unbekannt, widerrufen, abgelaufen) innerhalb von 15 Minuten antwortet ApiCore dieser IP-Adresse mit `429`, auch für richtige Schlüssel. Zusätzlich gilt das allgemeine Limit von 100 Anfragen je Minute und IP. | | Fehlversuche | Nach 20 abgelehnten Schlüsseln (fehlerhaft, unbekannt, widerrufen, abgelaufen) innerhalb von 15 Minuten antwortet ApiCore dieser IP-Adresse (bei IPv6 dem ganzen /64-Netz) mit `429`, auch für richtige Schlüssel. Zusätzlich gilt das allgemeine Limit von 100 Anfragen je Minute und IP. |
| Protokoll | Abgelehnte Schlüssel werden mit Grund, Präfix (`ack_abcd1234`) und IP protokolliert, nie mit dem Schlüssel. Query-Strings landen nicht im Log. | | Protokoll | Abgelehnte Schlüssel werden mit Grund, Präfix (`ack_abcd1234`) und IP protokolliert, nie mit dem Schlüssel. Query-Strings landen nicht im Log. |
| Trennung | Eigener Schlüssel nur für ERPNext. Der Wabenhain-Schlüssel (`shop-sync:*`) kommt an diese Endpunkte nicht heran und umgekehrt. | | Trennung | Eigener Schlüssel nur für ERPNext. Der Wabenhain-Schlüssel (`shop-sync:*`) kommt an diese Endpunkte nicht heran und umgekehrt. |