feat: Inventar-Export für ERPNext (erp-inventory:read) #1
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 helmet from 'helmet';
|
||||
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';
|
||||
|
||||
/**
|
||||
* How many reverse-proxy hops to trust for X-Forwarded-For. It decides the
|
||||
* client IP behind every rate limit and API-key IP allowlist: too few and
|
||||
* all users share the proxy's IP, too many 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").
|
||||
* Which proxies to trust for X-Forwarded-For. It decides the client IP behind
|
||||
* every rate limit and API-key IP allowlist: trust too little and all users
|
||||
* share the proxy's IP, trust too much and a client can forge its own.
|
||||
*
|
||||
* 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 {
|
||||
const raw = process.env.TRUST_PROXY?.trim();
|
||||
@@ -21,11 +30,29 @@ export function trustProxySetting(): number | string {
|
||||
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 {
|
||||
// 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
|
||||
// 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
|
||||
// responses that are meant to be no-store.
|
||||
app.set('etag', false);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
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 { PrismaModule } from './prisma/prisma.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
@@ -114,7 +115,7 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
|
||||
providers: [
|
||||
// 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.
|
||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||
{ provide: APP_GUARD, useClass: ClientIpThrottlerGuard },
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||
{ 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,8 +6,13 @@ metadata:
|
||||
# Requires cert-manager with a ClusterIssuer named "letsencrypt-prod"
|
||||
# (or swap for whatever issuer/cert mechanism the cluster already uses).
|
||||
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:
|
||||
ingressClassName: nginx
|
||||
# Der Cluster (RKE2) nutzt Traefik; nur damit wirkt die Middleware-Annotation oben.
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- api.apicore.example.com
|
||||
|
||||
@@ -6,8 +6,13 @@ metadata:
|
||||
# Requires cert-manager with a ClusterIssuer named "letsencrypt-prod"
|
||||
# (or swap for whatever issuer/cert mechanism the cluster already uses).
|
||||
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:
|
||||
ingressClassName: nginx
|
||||
# Der Cluster (RKE2) nutzt Traefik; nur damit wirkt die Middleware-Annotation oben.
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- apicore.example.com
|
||||
|
||||
Reference in new issue
Block a user