@open-mercato/shared 0.6.7-develop.6606.1.3b1ec9b1ea → 0.6.7-develop.6621.1.cdbe9dee3d
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/ratelimit/config.js +10 -1
- package/dist/lib/ratelimit/config.js.map +2 -2
- package/dist/lib/ratelimit/helpers.js +5 -3
- package/dist/lib/ratelimit/helpers.js.map +2 -2
- package/dist/lib/ratelimit/index.js +2 -1
- package/dist/lib/ratelimit/index.js.map +2 -2
- package/dist/lib/ratelimit/service.js +1 -1
- package/dist/lib/ratelimit/service.js.map +1 -1
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/ratelimit/__tests__/config.test.ts +12 -0
- package/src/lib/ratelimit/__tests__/helpers.test.ts +2 -2
- package/src/lib/ratelimit/__tests__/service.test.ts +1 -1
- package/src/lib/ratelimit/config.ts +11 -1
- package/src/lib/ratelimit/helpers.ts +9 -5
- package/src/lib/ratelimit/index.ts +1 -1
- package/src/lib/ratelimit/service.ts +1 -1
- package/src/lib/ratelimit/types.ts +1 -1
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
|
|
2
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
2
3
|
const VALID_STRATEGIES = ["memory", "redis"];
|
|
4
|
+
const logger = createLogger("ratelimit").child({ component: "config" });
|
|
3
5
|
function readRateLimitConfig() {
|
|
4
6
|
const strategy = process.env.RATE_LIMIT_STRATEGY ?? "memory";
|
|
5
7
|
if (!VALID_STRATEGIES.includes(strategy)) {
|
|
6
8
|
throw new Error(`Invalid RATE_LIMIT_STRATEGY "${strategy}". Must be one of: ${VALID_STRATEGIES.join(", ")}`);
|
|
7
9
|
}
|
|
8
|
-
const trustProxyDepth =
|
|
10
|
+
const trustProxyDepth = parseTrustProxyDepth(process.env.RATE_LIMIT_TRUST_PROXY_DEPTH);
|
|
9
11
|
const integrationTest = parseBooleanWithDefault(process.env.OM_INTEGRATION_TEST, false);
|
|
10
12
|
const enabled = integrationTest ? false : parseBooleanWithDefault(process.env.RATE_LIMIT_ENABLED, true);
|
|
11
13
|
return {
|
|
@@ -29,6 +31,13 @@ function parsePositiveInt(raw) {
|
|
|
29
31
|
const parsed = Number(raw);
|
|
30
32
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : void 0;
|
|
31
33
|
}
|
|
34
|
+
function parseTrustProxyDepth(raw) {
|
|
35
|
+
if (raw === void 0 || raw.trim() === "") return 0;
|
|
36
|
+
const parsed = Number(raw);
|
|
37
|
+
if (Number.isInteger(parsed) && parsed >= 0) return parsed;
|
|
38
|
+
logger.warn("Invalid RATE_LIMIT_TRUST_PROXY_DEPTH; using safe direct mode", { value: raw });
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
32
41
|
export {
|
|
33
42
|
readEndpointRateLimitConfig,
|
|
34
43
|
readRateLimitConfig
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/ratelimit/config.ts"],
|
|
4
|
-
"sourcesContent": ["import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport type { RateLimitConfig, RateLimitGlobalConfig, RateLimitStrategy } from './types'\n\nconst VALID_STRATEGIES: RateLimitStrategy[] = ['memory', 'redis']\n\nexport function readRateLimitConfig(): RateLimitGlobalConfig {\n const strategy = (process.env.RATE_LIMIT_STRATEGY ?? 'memory') as RateLimitStrategy\n if (!VALID_STRATEGIES.includes(strategy)) {\n throw new Error(`Invalid RATE_LIMIT_STRATEGY \"${strategy}\". Must be one of: ${VALID_STRATEGIES.join(', ')}`)\n }\n\n const trustProxyDepth =
|
|
5
|
-
"mappings": "AAAA,SAAS,+BAA+B;
|
|
4
|
+
"sourcesContent": ["import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { RateLimitConfig, RateLimitGlobalConfig, RateLimitStrategy } from './types'\n\nconst VALID_STRATEGIES: RateLimitStrategy[] = ['memory', 'redis']\nconst logger = createLogger('ratelimit').child({ component: 'config' })\n\nexport function readRateLimitConfig(): RateLimitGlobalConfig {\n const strategy = (process.env.RATE_LIMIT_STRATEGY ?? 'memory') as RateLimitStrategy\n if (!VALID_STRATEGIES.includes(strategy)) {\n throw new Error(`Invalid RATE_LIMIT_STRATEGY \"${strategy}\". Must be one of: ${VALID_STRATEGIES.join(', ')}`)\n }\n\n const trustProxyDepth = parseTrustProxyDepth(process.env.RATE_LIMIT_TRUST_PROXY_DEPTH)\n\n // Integration test runs disable rate limiting globally so suites do not\n // have to juggle per-endpoint bypass headers or reshape default caps.\n // The targeted OM_TEST_MODE + OM_TEST_AUTH_RATE_LIMIT_MODE=opt-in escape\n // hatch (checkAuthRateLimit) still works for suites that explicitly test\n // rate-limit behavior.\n const integrationTest = parseBooleanWithDefault(process.env.OM_INTEGRATION_TEST, false)\n const enabled = integrationTest\n ? false\n : parseBooleanWithDefault(process.env.RATE_LIMIT_ENABLED, true)\n\n return {\n enabled,\n strategy,\n keyPrefix: process.env.RATE_LIMIT_KEY_PREFIX ?? 'rl',\n redisUrl: process.env.REDIS_URL,\n trustProxyDepth,\n }\n}\n\n/**\n * Read per-endpoint rate limit config from environment variables with hardcoded defaults.\n * Environment variable names follow the pattern: RATE_LIMIT_{PREFIX}_POINTS, RATE_LIMIT_{PREFIX}_DURATION, etc.\n */\nexport function readEndpointRateLimitConfig(\n envPrefix: string,\n defaults: { points: number; duration: number; blockDuration?: number; keyPrefix: string },\n): RateLimitConfig {\n return {\n points: parsePositiveInt(process.env[`RATE_LIMIT_${envPrefix}_POINTS`]) ?? defaults.points,\n duration: parsePositiveInt(process.env[`RATE_LIMIT_${envPrefix}_DURATION`]) ?? defaults.duration,\n blockDuration: parsePositiveInt(process.env[`RATE_LIMIT_${envPrefix}_BLOCK_DURATION`]) ?? defaults.blockDuration,\n keyPrefix: defaults.keyPrefix,\n }\n}\n\nfunction parsePositiveInt(raw: string | undefined): number | undefined {\n if (raw === undefined || raw === '') return undefined\n const parsed = Number(raw)\n return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined\n}\n\nfunction parseTrustProxyDepth(raw: string | undefined): number {\n if (raw === undefined || raw.trim() === '') return 0\n const parsed = Number(raw)\n if (Number.isInteger(parsed) && parsed >= 0) return parsed\n logger.warn('Invalid RATE_LIMIT_TRUST_PROXY_DEPTH; using safe direct mode', { value: raw })\n return 0\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,+BAA+B;AACxC,SAAS,oBAAoB;AAG7B,MAAM,mBAAwC,CAAC,UAAU,OAAO;AAChE,MAAM,SAAS,aAAa,WAAW,EAAE,MAAM,EAAE,WAAW,SAAS,CAAC;AAE/D,SAAS,sBAA6C;AAC3D,QAAM,WAAY,QAAQ,IAAI,uBAAuB;AACrD,MAAI,CAAC,iBAAiB,SAAS,QAAQ,GAAG;AACxC,UAAM,IAAI,MAAM,gCAAgC,QAAQ,sBAAsB,iBAAiB,KAAK,IAAI,CAAC,EAAE;AAAA,EAC7G;AAEA,QAAM,kBAAkB,qBAAqB,QAAQ,IAAI,4BAA4B;AAOrF,QAAM,kBAAkB,wBAAwB,QAAQ,IAAI,qBAAqB,KAAK;AACtF,QAAM,UAAU,kBACZ,QACA,wBAAwB,QAAQ,IAAI,oBAAoB,IAAI;AAEhE,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,WAAW,QAAQ,IAAI,yBAAyB;AAAA,IAChD,UAAU,QAAQ,IAAI;AAAA,IACtB;AAAA,EACF;AACF;AAMO,SAAS,4BACd,WACA,UACiB;AACjB,SAAO;AAAA,IACL,QAAQ,iBAAiB,QAAQ,IAAI,cAAc,SAAS,SAAS,CAAC,KAAK,SAAS;AAAA,IACpF,UAAU,iBAAiB,QAAQ,IAAI,cAAc,SAAS,WAAW,CAAC,KAAK,SAAS;AAAA,IACxF,eAAe,iBAAiB,QAAQ,IAAI,cAAc,SAAS,iBAAiB,CAAC,KAAK,SAAS;AAAA,IACnG,WAAW,SAAS;AAAA,EACtB;AACF;AAEA,SAAS,iBAAiB,KAA6C;AACrE,MAAI,QAAQ,UAAa,QAAQ,GAAI,QAAO;AAC5C,QAAM,SAAS,OAAO,GAAG;AACzB,SAAO,OAAO,SAAS,MAAM,KAAK,UAAU,IAAI,SAAS;AAC3D;AAEA,SAAS,qBAAqB,KAAiC;AAC7D,MAAI,QAAQ,UAAa,IAAI,KAAK,MAAM,GAAI,QAAO;AACnD,QAAM,SAAS,OAAO,GAAG;AACzB,MAAI,OAAO,UAAU,MAAM,KAAK,UAAU,EAAG,QAAO;AACpD,SAAO,KAAK,gEAAgE,EAAE,OAAO,IAAI,CAAC;AAC1F,SAAO;AACT;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
const RATE_LIMIT_ERROR_KEY = "api.errors.rateLimit";
|
|
4
4
|
const RATE_LIMIT_ERROR_FALLBACK = "Too many requests. Please try again later.";
|
|
5
|
+
const RATE_LIMIT_FALLBACK_KEY = "global";
|
|
5
6
|
const rateLimitErrorSchema = z.object({
|
|
6
7
|
error: z.string().describe("Rate limit exceeded message")
|
|
7
8
|
});
|
|
@@ -26,19 +27,20 @@ async function checkRateLimit(rateLimiterService, config, key, errorMessage) {
|
|
|
26
27
|
}
|
|
27
28
|
function getClientIp(req, trustProxyDepth = 0) {
|
|
28
29
|
const forwarded = req.headers.get("x-forwarded-for");
|
|
29
|
-
if (trustProxyDepth <= 0) {
|
|
30
|
+
if (!Number.isInteger(trustProxyDepth) || trustProxyDepth <= 0) {
|
|
30
31
|
return null;
|
|
31
32
|
}
|
|
32
33
|
if (forwarded) {
|
|
33
34
|
const ips = forwarded.split(",").map((ip) => ip.trim());
|
|
34
35
|
const clientIndex = ips.length - trustProxyDepth;
|
|
35
|
-
return clientIndex >= 0 ? ips[clientIndex] :
|
|
36
|
+
return clientIndex >= 0 ? ips[clientIndex] || null : null;
|
|
36
37
|
}
|
|
37
|
-
return req.headers.get("x-real-ip")
|
|
38
|
+
return trustProxyDepth === 1 ? req.headers.get("x-real-ip")?.trim() || null : null;
|
|
38
39
|
}
|
|
39
40
|
export {
|
|
40
41
|
RATE_LIMIT_ERROR_FALLBACK,
|
|
41
42
|
RATE_LIMIT_ERROR_KEY,
|
|
43
|
+
RATE_LIMIT_FALLBACK_KEY,
|
|
42
44
|
checkRateLimit,
|
|
43
45
|
getClientIp,
|
|
44
46
|
rateLimitErrorSchema
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/ratelimit/helpers.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { RateLimitConfig } from './types'\nimport type { RateLimiterService } from './service'\n\nexport const RATE_LIMIT_ERROR_KEY = 'api.errors.rateLimit'\nexport const RATE_LIMIT_ERROR_FALLBACK = 'Too many requests. Please try again later.'\n\nexport const rateLimitErrorSchema = z.object({\n error: z.string().describe('Rate limit exceeded message'),\n})\n\n/**\n * Check rate limit for a request. Returns a 429 NextResponse if rate limited, or null if allowed.\n * Rate limit headers (X-RateLimit-*, Retry-After) are only included on 429 responses.\n */\nexport async function checkRateLimit(\n rateLimiterService: RateLimiterService,\n config: RateLimitConfig,\n key: string,\n errorMessage: string,\n): Promise<NextResponse | null> {\n const result = await rateLimiterService.consume(key, config)\n\n if (!result.allowed) {\n const retryAfterSec = Math.ceil(result.msBeforeNext / 1000)\n return NextResponse.json(\n { error: errorMessage },\n {\n status: 429,\n headers: {\n 'Retry-After': String(retryAfterSec),\n 'X-RateLimit-Limit': String(config.points),\n 'X-RateLimit-Remaining': String(result.remainingPoints),\n 'X-RateLimit-Reset': String(retryAfterSec),\n },\n },\n )\n }\n\n return null\n}\n\n/**\n * Extract client IP from a request, respecting reverse proxy trust depth.\n *\n * @param trustProxyDepth Number of trusted reverse proxies between the client and the app.\n * - 0 (default): Do not trust proxy-provided IP headers; return null.\n * - 1: One trusted proxy (e.g. nginx) \u2014 the last entry in X-Forwarded-For is the client IP.\n * - N: N trusted proxies \u2014 the Nth-from-last entry is the client IP.\n *\n * With depth=0, X-Forwarded-For and X-Real-IP are ignored entirely to prevent spoofing.\n */\nexport function getClientIp(req: Request, trustProxyDepth: number = 0): string | null {\n const forwarded = req.headers.get('x-forwarded-for')\n if (trustProxyDepth <= 0) {\n return null\n }\n\n if (forwarded) {\n const ips = forwarded.split(',').map((ip) => ip.trim())\n const clientIndex = ips.length - trustProxyDepth\n return clientIndex >= 0 ? ips[clientIndex] :
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAIX,MAAM,uBAAuB;AAC7B,MAAM,4BAA4B;
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { RateLimitConfig } from './types'\nimport type { RateLimiterService } from './service'\n\nexport const RATE_LIMIT_ERROR_KEY = 'api.errors.rateLimit'\nexport const RATE_LIMIT_ERROR_FALLBACK = 'Too many requests. Please try again later.'\nexport const RATE_LIMIT_FALLBACK_KEY = 'global'\n\nexport const rateLimitErrorSchema = z.object({\n error: z.string().describe('Rate limit exceeded message'),\n})\n\n/**\n * Check rate limit for a request. Returns a 429 NextResponse if rate limited, or null if allowed.\n * Rate limit headers (X-RateLimit-*, Retry-After) are only included on 429 responses.\n */\nexport async function checkRateLimit(\n rateLimiterService: RateLimiterService,\n config: RateLimitConfig,\n key: string,\n errorMessage: string,\n): Promise<NextResponse | null> {\n const result = await rateLimiterService.consume(key, config)\n\n if (!result.allowed) {\n const retryAfterSec = Math.ceil(result.msBeforeNext / 1000)\n return NextResponse.json(\n { error: errorMessage },\n {\n status: 429,\n headers: {\n 'Retry-After': String(retryAfterSec),\n 'X-RateLimit-Limit': String(config.points),\n 'X-RateLimit-Remaining': String(result.remainingPoints),\n 'X-RateLimit-Reset': String(retryAfterSec),\n },\n },\n )\n }\n\n return null\n}\n\n/**\n * Extract client IP from a request, respecting reverse proxy trust depth.\n *\n * @param trustProxyDepth Number of trusted reverse proxies between the client and the app.\n * - 0 (default): Do not trust proxy-provided IP headers; return null.\n * - 1: One trusted proxy (e.g. nginx) \u2014 the last entry in X-Forwarded-For is the client IP;\n * X-Real-IP is accepted only as a single-proxy fallback when X-Forwarded-For is absent.\n * - N: N trusted proxies \u2014 the Nth-from-last entry is the client IP. If the\n * forwarded chain is shorter than N, return null rather than trusting an\n * attacker-controlled entry.\n *\n * With depth=0, X-Forwarded-For and X-Real-IP are ignored entirely to prevent spoofing.\n */\nexport function getClientIp(req: Request, trustProxyDepth: number = 0): string | null {\n const forwarded = req.headers.get('x-forwarded-for')\n if (!Number.isInteger(trustProxyDepth) || trustProxyDepth <= 0) {\n return null\n }\n\n if (forwarded) {\n const ips = forwarded.split(',').map((ip) => ip.trim())\n const clientIndex = ips.length - trustProxyDepth\n return clientIndex >= 0 ? ips[clientIndex] || null : null\n }\n return trustProxyDepth === 1 ? req.headers.get('x-real-ip')?.trim() || null : null\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAIX,MAAM,uBAAuB;AAC7B,MAAM,4BAA4B;AAClC,MAAM,0BAA0B;AAEhC,MAAM,uBAAuB,EAAE,OAAO;AAAA,EAC3C,OAAO,EAAE,OAAO,EAAE,SAAS,6BAA6B;AAC1D,CAAC;AAMD,eAAsB,eACpB,oBACA,QACA,KACA,cAC8B;AAC9B,QAAM,SAAS,MAAM,mBAAmB,QAAQ,KAAK,MAAM;AAE3D,MAAI,CAAC,OAAO,SAAS;AACnB,UAAM,gBAAgB,KAAK,KAAK,OAAO,eAAe,GAAI;AAC1D,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,aAAa;AAAA,MACtB;AAAA,QACE,QAAQ;AAAA,QACR,SAAS;AAAA,UACP,eAAe,OAAO,aAAa;AAAA,UACnC,qBAAqB,OAAO,OAAO,MAAM;AAAA,UACzC,yBAAyB,OAAO,OAAO,eAAe;AAAA,UACtD,qBAAqB,OAAO,aAAa;AAAA,QAC3C;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAeO,SAAS,YAAY,KAAc,kBAA0B,GAAkB;AACpF,QAAM,YAAY,IAAI,QAAQ,IAAI,iBAAiB;AACnD,MAAI,CAAC,OAAO,UAAU,eAAe,KAAK,mBAAmB,GAAG;AAC9D,WAAO;AAAA,EACT;AAEA,MAAI,WAAW;AACb,UAAM,MAAM,UAAU,MAAM,GAAG,EAAE,IAAI,CAAC,OAAO,GAAG,KAAK,CAAC;AACtD,UAAM,cAAc,IAAI,SAAS;AACjC,WAAO,eAAe,IAAI,IAAI,WAAW,KAAK,OAAO;AAAA,EACvD;AACA,SAAO,oBAAoB,IAAI,IAAI,QAAQ,IAAI,WAAW,GAAG,KAAK,KAAK,OAAO;AAChF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { RateLimiterService } from "./service.js";
|
|
2
2
|
import { readRateLimitConfig, readEndpointRateLimitConfig } from "./config.js";
|
|
3
|
-
import { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK, rateLimitErrorSchema } from "./helpers.js";
|
|
3
|
+
import { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK, RATE_LIMIT_FALLBACK_KEY, rateLimitErrorSchema } from "./helpers.js";
|
|
4
4
|
export {
|
|
5
5
|
RATE_LIMIT_ERROR_FALLBACK,
|
|
6
6
|
RATE_LIMIT_ERROR_KEY,
|
|
7
|
+
RATE_LIMIT_FALLBACK_KEY,
|
|
7
8
|
RateLimiterService,
|
|
8
9
|
checkRateLimit,
|
|
9
10
|
getClientIp,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/ratelimit/index.ts"],
|
|
4
|
-
"sourcesContent": ["export { RateLimiterService } from './service'\nexport { readRateLimitConfig, readEndpointRateLimitConfig } from './config'\nexport { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK, rateLimitErrorSchema } from './helpers'\nexport type { RateLimitConfig, RateLimitResult, RateLimitStrategy, RateLimitGlobalConfig } from './types'\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,0BAA0B;AACnC,SAAS,qBAAqB,mCAAmC;AACjE,SAAS,gBAAgB,aAAa,sBAAsB,2BAA2B,4BAA4B;",
|
|
4
|
+
"sourcesContent": ["export { RateLimiterService } from './service'\nexport { readRateLimitConfig, readEndpointRateLimitConfig } from './config'\nexport { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK, RATE_LIMIT_FALLBACK_KEY, rateLimitErrorSchema } from './helpers'\nexport type { RateLimitConfig, RateLimitResult, RateLimitStrategy, RateLimitGlobalConfig } from './types'\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,0BAA0B;AACnC,SAAS,qBAAqB,mCAAmC;AACjE,SAAS,gBAAgB,aAAa,sBAAsB,2BAA2B,yBAAyB,4BAA4B;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -4,7 +4,7 @@ class RateLimiterService {
|
|
|
4
4
|
this.limiters = /* @__PURE__ */ new Map();
|
|
5
5
|
this.redisClient = null;
|
|
6
6
|
this.globalConfig = globalConfig;
|
|
7
|
-
this.trustProxyDepth = globalConfig.trustProxyDepth ??
|
|
7
|
+
this.trustProxyDepth = globalConfig.trustProxyDepth ?? 0;
|
|
8
8
|
}
|
|
9
9
|
async initialize() {
|
|
10
10
|
if (this.globalConfig.strategy === "redis" && this.globalConfig.redisUrl) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/ratelimit/service.ts"],
|
|
4
|
-
"sourcesContent": ["import { RateLimiterMemory, RateLimiterRedis, RateLimiterRes } from 'rate-limiter-flexible'\nimport type { RateLimitConfig, RateLimitResult, RateLimitGlobalConfig } from './types'\n\n/** Narrow interface for the ioredis client \u2014 only the methods we actually use. */\ninterface RedisClient {\n disconnect(): void\n}\n\nexport class RateLimiterService {\n private globalConfig: RateLimitGlobalConfig\n private limiters = new Map<string, RateLimiterMemory | RateLimiterRedis>()\n private redisClient: RedisClient | null = null\n\n readonly trustProxyDepth: number\n\n constructor(globalConfig: RateLimitGlobalConfig) {\n this.globalConfig = globalConfig\n this.trustProxyDepth = globalConfig.trustProxyDepth ??
|
|
4
|
+
"sourcesContent": ["import { RateLimiterMemory, RateLimiterRedis, RateLimiterRes } from 'rate-limiter-flexible'\nimport type { RateLimitConfig, RateLimitResult, RateLimitGlobalConfig } from './types'\n\n/** Narrow interface for the ioredis client \u2014 only the methods we actually use. */\ninterface RedisClient {\n disconnect(): void\n}\n\nexport class RateLimiterService {\n private globalConfig: RateLimitGlobalConfig\n private limiters = new Map<string, RateLimiterMemory | RateLimiterRedis>()\n private redisClient: RedisClient | null = null\n\n readonly trustProxyDepth: number\n\n constructor(globalConfig: RateLimitGlobalConfig) {\n this.globalConfig = globalConfig\n this.trustProxyDepth = globalConfig.trustProxyDepth ?? 0\n }\n\n async initialize(): Promise<void> {\n if (this.globalConfig.strategy === 'redis' && this.globalConfig.redisUrl) {\n const { default: Redis } = await import('ioredis')\n this.redisClient = new Redis(this.globalConfig.redisUrl, {\n enableOfflineQueue: false,\n maxRetriesPerRequest: 1,\n })\n }\n }\n\n async consume(key: string, config: RateLimitConfig): Promise<RateLimitResult> {\n if (!this.globalConfig.enabled) {\n return this.disabledResult(config)\n }\n\n const limiter = this.getOrCreateLimiter(config)\n\n try {\n const res = await limiter.consume(key, 1)\n return this.toResult(res, true)\n } catch (error) {\n if (error instanceof RateLimiterRes) {\n return this.toResult(error, false)\n }\n return this.disabledResult(config)\n }\n }\n\n async get(key: string, config: RateLimitConfig): Promise<RateLimitResult | null> {\n if (!this.globalConfig.enabled) return null\n\n const limiter = this.getOrCreateLimiter(config)\n const res = await limiter.get(key)\n return res ? this.toResult(res, res.remainingPoints > 0) : null\n }\n\n async delete(key: string, config: RateLimitConfig): Promise<void> {\n if (!this.globalConfig.enabled) return\n const limiter = this.getOrCreateLimiter(config)\n await limiter.delete(key)\n }\n\n async penalty(key: string, points: number, config: RateLimitConfig): Promise<RateLimitResult> {\n if (!this.globalConfig.enabled) {\n return this.disabledResult(config)\n }\n const limiter = this.getOrCreateLimiter(config)\n const res = await limiter.penalty(key, points)\n return this.toResult(res, res.remainingPoints > 0)\n }\n\n async reward(key: string, points: number, config: RateLimitConfig): Promise<RateLimitResult> {\n if (!this.globalConfig.enabled) {\n return this.disabledResult(config)\n }\n const limiter = this.getOrCreateLimiter(config)\n const res = await limiter.reward(key, points)\n return this.toResult(res, true)\n }\n\n async block(key: string, durationSec: number, config: RateLimitConfig): Promise<void> {\n if (!this.globalConfig.enabled) return\n const limiter = this.getOrCreateLimiter(config)\n await limiter.block(key, durationSec)\n }\n\n async destroy(): Promise<void> {\n if (this.redisClient) {\n this.redisClient.disconnect()\n }\n this.limiters.clear()\n }\n\n private disabledResult(config: RateLimitConfig): RateLimitResult {\n return { allowed: true, remainingPoints: config.points, msBeforeNext: 0, consumedPoints: 0 }\n }\n\n private getOrCreateLimiter(config: RateLimitConfig): RateLimiterMemory | RateLimiterRedis {\n const cacheKey = `${config.keyPrefix ?? 'default'}:${config.points}:${config.duration}:${config.blockDuration ?? 0}`\n\n let limiter = this.limiters.get(cacheKey)\n if (limiter) return limiter\n\n const prefix = [this.globalConfig.keyPrefix, config.keyPrefix].filter(Boolean).join(':')\n\n const baseOpts = {\n keyPrefix: prefix,\n points: config.points,\n duration: config.duration,\n blockDuration: config.blockDuration ?? 0,\n }\n\n if (this.globalConfig.strategy === 'redis' && this.redisClient) {\n const insuranceLimiter = new RateLimiterMemory(baseOpts)\n limiter = new RateLimiterRedis({\n ...baseOpts,\n storeClient: this.redisClient,\n insuranceLimiter,\n rejectIfRedisNotReady: false,\n })\n } else {\n limiter = new RateLimiterMemory(baseOpts)\n }\n\n this.limiters.set(cacheKey, limiter)\n return limiter\n }\n\n private toResult(res: RateLimiterRes, allowed: boolean): RateLimitResult {\n return {\n allowed,\n remainingPoints: Math.max(res.remainingPoints, 0),\n msBeforeNext: res.msBeforeNext,\n consumedPoints: res.consumedPoints,\n }\n }\n}\n"],
|
|
5
5
|
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.7-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.7-develop.6621.1.cdbe9dee3d'\nexport const appVersion = APP_VERSION\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.6.7-develop.
|
|
3
|
+
"version": "0.6.7-develop.6621.1.cdbe9dee3d",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"@mikro-orm/core": "^7.1.5",
|
|
98
98
|
"@mikro-orm/decorators": "^7.1.5",
|
|
99
99
|
"@mikro-orm/postgresql": "^7.1.5",
|
|
100
|
-
"@open-mercato/cache": "0.6.7-develop.
|
|
100
|
+
"@open-mercato/cache": "0.6.7-develop.6621.1.cdbe9dee3d",
|
|
101
101
|
"@types/sanitize-html": "^2.16.1",
|
|
102
102
|
"dotenv": "^17.4.2",
|
|
103
103
|
"pino": "^10.3.1",
|
|
@@ -13,6 +13,18 @@ describe('readRateLimitConfig', () => {
|
|
|
13
13
|
expect(readRateLimitConfig().enabled).toBe(true)
|
|
14
14
|
})
|
|
15
15
|
|
|
16
|
+
it('defaults to direct mode when proxy depth is not configured', () => {
|
|
17
|
+
delete process.env.RATE_LIMIT_TRUST_PROXY_DEPTH
|
|
18
|
+
|
|
19
|
+
expect(readRateLimitConfig().trustProxyDepth).toBe(0)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it.each(['-1', '1.5', 'not-a-number'])('falls back to direct mode for invalid proxy depth %s', (value) => {
|
|
23
|
+
process.env.RATE_LIMIT_TRUST_PROXY_DEPTH = value
|
|
24
|
+
|
|
25
|
+
expect(readRateLimitConfig().trustProxyDepth).toBe(0)
|
|
26
|
+
})
|
|
27
|
+
|
|
16
28
|
it('honors RATE_LIMIT_ENABLED=false', () => {
|
|
17
29
|
process.env.RATE_LIMIT_ENABLED = 'false'
|
|
18
30
|
delete process.env.OM_INTEGRATION_TEST
|
|
@@ -132,11 +132,11 @@ describe('getClientIp', () => {
|
|
|
132
132
|
expect(getClientIp(req, 2)).toBe('real-client')
|
|
133
133
|
})
|
|
134
134
|
|
|
135
|
-
it('
|
|
135
|
+
it('returns null when the forwarded chain is shorter than the configured trust depth', () => {
|
|
136
136
|
const req = new Request('http://localhost', {
|
|
137
137
|
headers: { 'x-forwarded-for': '192.168.1.1' },
|
|
138
138
|
})
|
|
139
|
-
expect(getClientIp(req, 3)).
|
|
139
|
+
expect(getClientIp(req, 3)).toBeNull()
|
|
140
140
|
})
|
|
141
141
|
|
|
142
142
|
it('trims whitespace from x-forwarded-for entries', () => {
|
|
@@ -220,7 +220,7 @@ describe('RateLimiterService', () => {
|
|
|
220
220
|
expect(config.enabled).toBe(true)
|
|
221
221
|
expect(config.strategy).toBe('memory')
|
|
222
222
|
expect(config.keyPrefix).toBe('rl')
|
|
223
|
-
expect(config.trustProxyDepth).toBe(
|
|
223
|
+
expect(config.trustProxyDepth).toBe(0)
|
|
224
224
|
|
|
225
225
|
if (originalEnabled !== undefined) process.env.RATE_LIMIT_ENABLED = originalEnabled; else delete process.env.RATE_LIMIT_ENABLED
|
|
226
226
|
if (originalStrategy !== undefined) process.env.RATE_LIMIT_STRATEGY = originalStrategy; else delete process.env.RATE_LIMIT_STRATEGY
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
2
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
2
3
|
import type { RateLimitConfig, RateLimitGlobalConfig, RateLimitStrategy } from './types'
|
|
3
4
|
|
|
4
5
|
const VALID_STRATEGIES: RateLimitStrategy[] = ['memory', 'redis']
|
|
6
|
+
const logger = createLogger('ratelimit').child({ component: 'config' })
|
|
5
7
|
|
|
6
8
|
export function readRateLimitConfig(): RateLimitGlobalConfig {
|
|
7
9
|
const strategy = (process.env.RATE_LIMIT_STRATEGY ?? 'memory') as RateLimitStrategy
|
|
@@ -9,7 +11,7 @@ export function readRateLimitConfig(): RateLimitGlobalConfig {
|
|
|
9
11
|
throw new Error(`Invalid RATE_LIMIT_STRATEGY "${strategy}". Must be one of: ${VALID_STRATEGIES.join(', ')}`)
|
|
10
12
|
}
|
|
11
13
|
|
|
12
|
-
const trustProxyDepth =
|
|
14
|
+
const trustProxyDepth = parseTrustProxyDepth(process.env.RATE_LIMIT_TRUST_PROXY_DEPTH)
|
|
13
15
|
|
|
14
16
|
// Integration test runs disable rate limiting globally so suites do not
|
|
15
17
|
// have to juggle per-endpoint bypass headers or reshape default caps.
|
|
@@ -51,3 +53,11 @@ function parsePositiveInt(raw: string | undefined): number | undefined {
|
|
|
51
53
|
const parsed = Number(raw)
|
|
52
54
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined
|
|
53
55
|
}
|
|
56
|
+
|
|
57
|
+
function parseTrustProxyDepth(raw: string | undefined): number {
|
|
58
|
+
if (raw === undefined || raw.trim() === '') return 0
|
|
59
|
+
const parsed = Number(raw)
|
|
60
|
+
if (Number.isInteger(parsed) && parsed >= 0) return parsed
|
|
61
|
+
logger.warn('Invalid RATE_LIMIT_TRUST_PROXY_DEPTH; using safe direct mode', { value: raw })
|
|
62
|
+
return 0
|
|
63
|
+
}
|
|
@@ -5,6 +5,7 @@ import type { RateLimiterService } from './service'
|
|
|
5
5
|
|
|
6
6
|
export const RATE_LIMIT_ERROR_KEY = 'api.errors.rateLimit'
|
|
7
7
|
export const RATE_LIMIT_ERROR_FALLBACK = 'Too many requests. Please try again later.'
|
|
8
|
+
export const RATE_LIMIT_FALLBACK_KEY = 'global'
|
|
8
9
|
|
|
9
10
|
export const rateLimitErrorSchema = z.object({
|
|
10
11
|
error: z.string().describe('Rate limit exceeded message'),
|
|
@@ -46,21 +47,24 @@ export async function checkRateLimit(
|
|
|
46
47
|
*
|
|
47
48
|
* @param trustProxyDepth Number of trusted reverse proxies between the client and the app.
|
|
48
49
|
* - 0 (default): Do not trust proxy-provided IP headers; return null.
|
|
49
|
-
* - 1: One trusted proxy (e.g. nginx) — the last entry in X-Forwarded-For is the client IP
|
|
50
|
-
*
|
|
50
|
+
* - 1: One trusted proxy (e.g. nginx) — the last entry in X-Forwarded-For is the client IP;
|
|
51
|
+
* X-Real-IP is accepted only as a single-proxy fallback when X-Forwarded-For is absent.
|
|
52
|
+
* - N: N trusted proxies — the Nth-from-last entry is the client IP. If the
|
|
53
|
+
* forwarded chain is shorter than N, return null rather than trusting an
|
|
54
|
+
* attacker-controlled entry.
|
|
51
55
|
*
|
|
52
56
|
* With depth=0, X-Forwarded-For and X-Real-IP are ignored entirely to prevent spoofing.
|
|
53
57
|
*/
|
|
54
58
|
export function getClientIp(req: Request, trustProxyDepth: number = 0): string | null {
|
|
55
59
|
const forwarded = req.headers.get('x-forwarded-for')
|
|
56
|
-
if (trustProxyDepth <= 0) {
|
|
60
|
+
if (!Number.isInteger(trustProxyDepth) || trustProxyDepth <= 0) {
|
|
57
61
|
return null
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
if (forwarded) {
|
|
61
65
|
const ips = forwarded.split(',').map((ip) => ip.trim())
|
|
62
66
|
const clientIndex = ips.length - trustProxyDepth
|
|
63
|
-
return clientIndex >= 0 ? ips[clientIndex] :
|
|
67
|
+
return clientIndex >= 0 ? ips[clientIndex] || null : null
|
|
64
68
|
}
|
|
65
|
-
return req.headers.get('x-real-ip')
|
|
69
|
+
return trustProxyDepth === 1 ? req.headers.get('x-real-ip')?.trim() || null : null
|
|
66
70
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { RateLimiterService } from './service'
|
|
2
2
|
export { readRateLimitConfig, readEndpointRateLimitConfig } from './config'
|
|
3
|
-
export { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK, rateLimitErrorSchema } from './helpers'
|
|
3
|
+
export { checkRateLimit, getClientIp, RATE_LIMIT_ERROR_KEY, RATE_LIMIT_ERROR_FALLBACK, RATE_LIMIT_FALLBACK_KEY, rateLimitErrorSchema } from './helpers'
|
|
4
4
|
export type { RateLimitConfig, RateLimitResult, RateLimitStrategy, RateLimitGlobalConfig } from './types'
|
|
@@ -15,7 +15,7 @@ export class RateLimiterService {
|
|
|
15
15
|
|
|
16
16
|
constructor(globalConfig: RateLimitGlobalConfig) {
|
|
17
17
|
this.globalConfig = globalConfig
|
|
18
|
-
this.trustProxyDepth = globalConfig.trustProxyDepth ??
|
|
18
|
+
this.trustProxyDepth = globalConfig.trustProxyDepth ?? 0
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
async initialize(): Promise<void> {
|
|
@@ -27,6 +27,6 @@ export interface RateLimitGlobalConfig {
|
|
|
27
27
|
strategy: RateLimitStrategy
|
|
28
28
|
keyPrefix: string
|
|
29
29
|
redisUrl?: string
|
|
30
|
-
/** Number of trusted reverse proxies for X-Forwarded-For IP extraction (default:
|
|
30
|
+
/** Number of trusted reverse proxies for X-Forwarded-For IP extraction (default: 0) */
|
|
31
31
|
trustProxyDepth: number
|
|
32
32
|
}
|