@open-mercato/shared 0.5.1-develop.2855.9b058b7483 → 0.5.1-develop.2860.07af3a6a9d

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.
@@ -6,8 +6,10 @@ function readRateLimitConfig() {
6
6
  throw new Error(`Invalid RATE_LIMIT_STRATEGY "${strategy}". Must be one of: ${VALID_STRATEGIES.join(", ")}`);
7
7
  }
8
8
  const trustProxyDepth = parsePositiveInt(process.env.RATE_LIMIT_TRUST_PROXY_DEPTH) ?? 1;
9
+ const integrationTest = parseBooleanWithDefault(process.env.OM_INTEGRATION_TEST, false);
10
+ const enabled = integrationTest ? false : parseBooleanWithDefault(process.env.RATE_LIMIT_ENABLED, true);
9
11
  return {
10
- enabled: parseBooleanWithDefault(process.env.RATE_LIMIT_ENABLED, true),
12
+ enabled,
11
13
  strategy,
12
14
  keyPrefix: process.env.RATE_LIMIT_KEY_PREFIX ?? "rl",
13
15
  redisUrl: process.env.REDIS_URL,
@@ -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 = parsePositiveInt(process.env.RATE_LIMIT_TRUST_PROXY_DEPTH) ?? 1\n\n return {\n enabled: parseBooleanWithDefault(process.env.RATE_LIMIT_ENABLED, true),\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"],
5
- "mappings": "AAAA,SAAS,+BAA+B;AAGxC,MAAM,mBAAwC,CAAC,UAAU,OAAO;AAEzD,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,iBAAiB,QAAQ,IAAI,4BAA4B,KAAK;AAEtF,SAAO;AAAA,IACL,SAAS,wBAAwB,QAAQ,IAAI,oBAAoB,IAAI;AAAA,IACrE;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;",
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 = parsePositiveInt(process.env.RATE_LIMIT_TRUST_PROXY_DEPTH) ?? 1\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"],
5
+ "mappings": "AAAA,SAAS,+BAA+B;AAGxC,MAAM,mBAAwC,CAAC,UAAU,OAAO;AAEzD,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,iBAAiB,QAAQ,IAAI,4BAA4B,KAAK;AAOtF,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;",
6
6
  "names": []
7
7
  }
package/dist/lib/url.js CHANGED
@@ -51,27 +51,64 @@ function originFromHost(protocol, rawHost) {
51
51
  if (!host) return null;
52
52
  return normalizeOrigin(`${protocol}//${host}`);
53
53
  }
54
- function requestOrigins(input) {
55
- const origins = /* @__PURE__ */ new Set();
56
- if (!input) return origins;
54
+ function readRequestOriginCandidates(input) {
55
+ const candidates = {
56
+ urlOrigin: null,
57
+ headerOrigins: /* @__PURE__ */ new Set()
58
+ };
59
+ if (!input) return candidates;
57
60
  const requestUrl = typeof input === "string" ? input : input.url;
58
61
  let parsedUrl;
59
62
  try {
60
63
  parsedUrl = new URL(requestUrl);
61
64
  } catch {
62
- return origins;
65
+ return candidates;
63
66
  }
64
- const urlOrigin = normalizeOrigin(parsedUrl.origin);
65
- if (urlOrigin) origins.add(urlOrigin);
66
- if (typeof input === "string") return origins;
67
+ candidates.urlOrigin = normalizeOrigin(parsedUrl.origin);
68
+ if (typeof input === "string") return candidates;
67
69
  const forwardedProto = readFirstHeaderValue(input.headers.get("x-forwarded-proto")) ?? parsedUrl.protocol.replace(/:$/, "");
68
70
  const protocol = forwardedProto.endsWith(":") ? forwardedProto : `${forwardedProto}:`;
69
71
  const hostOrigin = originFromHost(protocol, input.headers.get("host"));
70
72
  const forwardedHostOrigin = originFromHost(protocol, input.headers.get("x-forwarded-host"));
71
- if (hostOrigin) origins.add(hostOrigin);
72
- if (forwardedHostOrigin) origins.add(forwardedHostOrigin);
73
+ if (hostOrigin) candidates.headerOrigins.add(hostOrigin);
74
+ if (forwardedHostOrigin) candidates.headerOrigins.add(forwardedHostOrigin);
75
+ return candidates;
76
+ }
77
+ function requestOrigins(input) {
78
+ const origins = /* @__PURE__ */ new Set();
79
+ const { urlOrigin, headerOrigins } = readRequestOriginCandidates(input);
80
+ if (urlOrigin) origins.add(urlOrigin);
81
+ for (const origin of headerOrigins) origins.add(origin);
73
82
  return origins;
74
83
  }
84
+ function logOriginDebugContext(input, allowedOrigins, rejectedOrigin, level = "error", nodeEnv) {
85
+ if (level === "warn" && nodeEnv === "test") return;
86
+ const log = level === "warn" ? console.warn : console.error;
87
+ if (typeof input === "string") {
88
+ log("[origin-check] rejected string input", {
89
+ requestUrl: input,
90
+ rejectedOrigin: rejectedOrigin ?? null,
91
+ allowedOrigins: Array.from(allowedOrigins)
92
+ });
93
+ return;
94
+ }
95
+ if (!input) {
96
+ log("[origin-check] rejected empty input", {
97
+ rejectedOrigin: rejectedOrigin ?? null,
98
+ allowedOrigins: Array.from(allowedOrigins)
99
+ });
100
+ return;
101
+ }
102
+ log("[origin-check] rejected request", {
103
+ requestUrl: input.url,
104
+ host: input.headers.get("host"),
105
+ forwardedHost: input.headers.get("x-forwarded-host"),
106
+ forwardedProto: input.headers.get("x-forwarded-proto"),
107
+ derivedOrigins: Array.from(requestOrigins(input)),
108
+ rejectedOrigin: rejectedOrigin ?? null,
109
+ allowedOrigins: Array.from(allowedOrigins)
110
+ });
111
+ }
75
112
  function isLoopbackHostname(hostname) {
76
113
  return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
77
114
  }
@@ -113,18 +150,25 @@ function assertAllowedAppOrigin(input, env = process.env) {
113
150
  const allowedOrigins = readAllowedOrigins(env);
114
151
  if (allowedOrigins.size === 0) {
115
152
  if (env.NODE_ENV === "production") {
153
+ logOriginDebugContext(input, allowedOrigins);
116
154
  throw new AppOriginConfigurationError("APP_URL must be configured in production");
117
155
  }
118
156
  return;
119
157
  }
120
- const origins = requestOrigins(input);
121
- if (origins.size === 0) return;
122
- for (const origin of origins) {
123
- if (!allowedOrigins.has(origin)) {
124
- if (shouldAllowLoopbackOrigin(origin, allowedOrigins, env)) continue;
125
- throw new AppOriginRejectedError("Request origin is not allowed");
126
- }
158
+ const { urlOrigin, headerOrigins } = readRequestOriginCandidates(input);
159
+ const hasAllowedHeaderOrigin = Array.from(headerOrigins).some((origin) => allowedOrigins.has(origin));
160
+ for (const origin of headerOrigins) {
161
+ if (allowedOrigins.has(origin)) continue;
162
+ if (shouldAllowLoopbackOrigin(origin, allowedOrigins, env)) continue;
163
+ logOriginDebugContext(input, allowedOrigins, origin, "warn", env.NODE_ENV);
164
+ throw new AppOriginRejectedError("Request origin is not allowed");
127
165
  }
166
+ if (!urlOrigin) return;
167
+ if (allowedOrigins.has(urlOrigin)) return;
168
+ if (shouldAllowLoopbackOrigin(urlOrigin, allowedOrigins, env)) return;
169
+ if (isLoopbackOrigin(urlOrigin) && hasAllowedHeaderOrigin) return;
170
+ logOriginDebugContext(input, allowedOrigins, urlOrigin, "warn", env.NODE_ENV);
171
+ throw new AppOriginRejectedError("Request origin is not allowed");
128
172
  }
129
173
  function resolveRequestOrigin(req) {
130
174
  const url = new URL(req.url);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/url.ts"],
4
- "sourcesContent": ["const DEFAULT_DEV_APP_URL = 'http://localhost:3000'\n\ntype EnvLike = Record<string, string | undefined> & {\n APP_URL?: string\n NEXT_PUBLIC_APP_URL?: string\n APP_ALLOWED_ORIGINS?: string\n NODE_ENV?: string\n}\ntype RequestInput = Request | string | undefined\n\nexport type SecurityEmailUrlErrorMapping = {\n scope: string\n configMessage: string\n}\n\nexport class AppOriginConfigurationError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'AppOriginConfigurationError'\n }\n}\n\nexport class AppOriginRejectedError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'AppOriginRejectedError'\n }\n}\n\nfunction parseBaseUrl(raw: string | undefined): URL | null {\n const value = raw?.trim()\n if (!value) return null\n try {\n const url = new URL(value)\n url.hash = ''\n url.search = ''\n return url\n } catch {\n return null\n }\n}\n\nfunction normalizeBaseUrl(raw: string | undefined): string | null {\n const url = parseBaseUrl(raw)\n return url ? url.toString().replace(/\\/$/, '') : null\n}\n\nfunction normalizeOrigin(raw: string | undefined): string | null {\n const url = parseBaseUrl(raw)\n return url ? url.origin : null\n}\n\nfunction readCsv(value: string | undefined): string[] {\n return (value ?? '')\n .split(',')\n .map((item) => item.trim())\n .filter((item) => item.length > 0)\n}\n\nfunction readAllowedOrigins(env: EnvLike): Set<string> {\n const origins = new Set<string>()\n for (const raw of [env.APP_URL, env.NEXT_PUBLIC_APP_URL, ...readCsv(env.APP_ALLOWED_ORIGINS)]) {\n const origin = normalizeOrigin(raw)\n if (origin) origins.add(origin)\n }\n return origins\n}\n\nfunction readFirstHeaderValue(value: string | null): string | null {\n const first = value?.split(',')[0]?.trim()\n return first && first.length > 0 ? first : null\n}\n\nfunction originFromHost(protocol: string, rawHost: string | null): string | null {\n const host = readFirstHeaderValue(rawHost)\n if (!host) return null\n return normalizeOrigin(`${protocol}//${host}`)\n}\n\nfunction requestOrigins(input: RequestInput): Set<string> {\n const origins = new Set<string>()\n if (!input) return origins\n\n const requestUrl = typeof input === 'string' ? input : input.url\n let parsedUrl: URL\n try {\n parsedUrl = new URL(requestUrl)\n } catch {\n return origins\n }\n\n const urlOrigin = normalizeOrigin(parsedUrl.origin)\n if (urlOrigin) origins.add(urlOrigin)\n\n if (typeof input === 'string') return origins\n\n const forwardedProto = readFirstHeaderValue(input.headers.get('x-forwarded-proto')) ?? parsedUrl.protocol.replace(/:$/, '')\n const protocol = forwardedProto.endsWith(':') ? forwardedProto : `${forwardedProto}:`\n const hostOrigin = originFromHost(protocol, input.headers.get('host'))\n const forwardedHostOrigin = originFromHost(protocol, input.headers.get('x-forwarded-host'))\n if (hostOrigin) origins.add(hostOrigin)\n if (forwardedHostOrigin) origins.add(forwardedHostOrigin)\n return origins\n}\n\nfunction isLoopbackHostname(hostname: string): boolean {\n return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'\n}\n\nfunction isLoopbackOrigin(origin: string): boolean {\n try {\n return isLoopbackHostname(new URL(origin).hostname)\n } catch {\n return false\n }\n}\n\nfunction normalizeOriginPort(url: URL): string {\n if (url.port) return url.port\n if (url.protocol === 'https:') return '443'\n if (url.protocol === 'http:') return '80'\n return ''\n}\n\nfunction isEquivalentLoopbackOrigin(origin: string, allowedOrigin: string): boolean {\n try {\n const candidateUrl = new URL(origin)\n const allowedUrl = new URL(allowedOrigin)\n if (!isLoopbackHostname(candidateUrl.hostname) || !isLoopbackHostname(allowedUrl.hostname)) {\n return false\n }\n return normalizeOriginPort(candidateUrl) === normalizeOriginPort(allowedUrl)\n } catch {\n return false\n }\n}\n\nfunction shouldAllowLoopbackOrigin(origin: string, allowedOrigins: Set<string>, env: EnvLike): boolean {\n if (!isLoopbackOrigin(origin)) return false\n for (const allowedOrigin of allowedOrigins) {\n if (!isLoopbackOrigin(allowedOrigin)) continue\n if (env.NODE_ENV !== 'production') return true\n if (isEquivalentLoopbackOrigin(origin, allowedOrigin)) return true\n }\n return false\n}\n\nexport function assertAllowedAppOrigin(input: RequestInput, env: EnvLike = process.env): void {\n const allowedOrigins = readAllowedOrigins(env)\n if (allowedOrigins.size === 0) {\n if (env.NODE_ENV === 'production') {\n throw new AppOriginConfigurationError('APP_URL must be configured in production')\n }\n return\n }\n const origins = requestOrigins(input)\n if (origins.size === 0) return\n\n for (const origin of origins) {\n if (!allowedOrigins.has(origin)) {\n if (shouldAllowLoopbackOrigin(origin, allowedOrigins, env)) continue\n throw new AppOriginRejectedError('Request origin is not allowed')\n }\n }\n}\n\nexport function resolveRequestOrigin(req: Request): string {\n const url = new URL(req.url)\n const proto = req.headers.get('x-forwarded-proto') || url.protocol.replace(':', '')\n const host = req.headers.get('x-forwarded-host') || req.headers.get('host') || url.host\n return `${proto}://${host}`\n}\n\nexport function getAppBaseUrl(req: Request): string {\n return (\n process.env.NEXT_PUBLIC_APP_URL ||\n process.env.APP_URL ||\n resolveRequestOrigin(req)\n )\n}\n\nexport function toAbsoluteUrl(req: Request, path: string): string {\n return new URL(path, getAppBaseUrl(req)).toString()\n}\n\nexport function getSecurityEmailBaseUrl(input?: RequestInput, env: EnvLike = process.env): string {\n const configuredAppUrl = normalizeBaseUrl(env.APP_URL)\n if (!configuredAppUrl) {\n if (env.NODE_ENV === 'production') {\n throw new AppOriginConfigurationError('APP_URL must be configured in production')\n }\n return DEFAULT_DEV_APP_URL\n }\n\n assertAllowedAppOrigin(input, env)\n return configuredAppUrl\n}\n\nexport function toSecurityEmailUrl(input: RequestInput, path: string, env: EnvLike = process.env): string {\n const base = getSecurityEmailBaseUrl(input, env)\n return `${base}/${path.replace(/^\\/+/, '')}`\n}\n\nexport function mapSecurityEmailUrlError(\n error: unknown,\n mapping: SecurityEmailUrlErrorMapping,\n): { status: number; body: { error: string } } | null {\n if (error instanceof AppOriginRejectedError) {\n return { status: 400, body: { error: 'Invalid request origin' } }\n }\n if (error instanceof AppOriginConfigurationError) {\n console.error(`[${mapping.scope}] APP_URL is required in production`)\n return { status: 500, body: { error: mapping.configMessage } }\n }\n return null\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["const DEFAULT_DEV_APP_URL = 'http://localhost:3000'\n\ntype EnvLike = Record<string, string | undefined> & {\n APP_URL?: string\n NEXT_PUBLIC_APP_URL?: string\n APP_ALLOWED_ORIGINS?: string\n NODE_ENV?: string\n}\ntype RequestInput = Request | string | undefined\n\nexport type SecurityEmailUrlErrorMapping = {\n scope: string\n configMessage: string\n}\n\nexport class AppOriginConfigurationError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'AppOriginConfigurationError'\n }\n}\n\nexport class AppOriginRejectedError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'AppOriginRejectedError'\n }\n}\n\nfunction parseBaseUrl(raw: string | undefined): URL | null {\n const value = raw?.trim()\n if (!value) return null\n try {\n const url = new URL(value)\n url.hash = ''\n url.search = ''\n return url\n } catch {\n return null\n }\n}\n\nfunction normalizeBaseUrl(raw: string | undefined): string | null {\n const url = parseBaseUrl(raw)\n return url ? url.toString().replace(/\\/$/, '') : null\n}\n\nfunction normalizeOrigin(raw: string | undefined): string | null {\n const url = parseBaseUrl(raw)\n return url ? url.origin : null\n}\n\nfunction readCsv(value: string | undefined): string[] {\n return (value ?? '')\n .split(',')\n .map((item) => item.trim())\n .filter((item) => item.length > 0)\n}\n\nfunction readAllowedOrigins(env: EnvLike): Set<string> {\n const origins = new Set<string>()\n for (const raw of [env.APP_URL, env.NEXT_PUBLIC_APP_URL, ...readCsv(env.APP_ALLOWED_ORIGINS)]) {\n const origin = normalizeOrigin(raw)\n if (origin) origins.add(origin)\n }\n return origins\n}\n\nfunction readFirstHeaderValue(value: string | null): string | null {\n const first = value?.split(',')[0]?.trim()\n return first && first.length > 0 ? first : null\n}\n\nfunction originFromHost(protocol: string, rawHost: string | null): string | null {\n const host = readFirstHeaderValue(rawHost)\n if (!host) return null\n return normalizeOrigin(`${protocol}//${host}`)\n}\n\ntype RequestOriginCandidates = {\n urlOrigin: string | null\n headerOrigins: Set<string>\n}\n\nfunction readRequestOriginCandidates(input: RequestInput): RequestOriginCandidates {\n const candidates: RequestOriginCandidates = {\n urlOrigin: null,\n headerOrigins: new Set<string>(),\n }\n if (!input) return candidates\n\n const requestUrl = typeof input === 'string' ? input : input.url\n let parsedUrl: URL\n try {\n parsedUrl = new URL(requestUrl)\n } catch {\n return candidates\n }\n\n candidates.urlOrigin = normalizeOrigin(parsedUrl.origin)\n if (typeof input === 'string') return candidates\n\n const forwardedProto = readFirstHeaderValue(input.headers.get('x-forwarded-proto')) ?? parsedUrl.protocol.replace(/:$/, '')\n const protocol = forwardedProto.endsWith(':') ? forwardedProto : `${forwardedProto}:`\n const hostOrigin = originFromHost(protocol, input.headers.get('host'))\n const forwardedHostOrigin = originFromHost(protocol, input.headers.get('x-forwarded-host'))\n if (hostOrigin) candidates.headerOrigins.add(hostOrigin)\n if (forwardedHostOrigin) candidates.headerOrigins.add(forwardedHostOrigin)\n return candidates\n}\n\nfunction requestOrigins(input: RequestInput): Set<string> {\n const origins = new Set<string>()\n const { urlOrigin, headerOrigins } = readRequestOriginCandidates(input)\n if (urlOrigin) origins.add(urlOrigin)\n for (const origin of headerOrigins) origins.add(origin)\n return origins\n}\n\nfunction logOriginDebugContext(\n input: RequestInput,\n allowedOrigins: Set<string>,\n rejectedOrigin?: string,\n level: 'error' | 'warn' = 'error',\n nodeEnv?: string,\n): void {\n if (level === 'warn' && nodeEnv === 'test') return\n const log = level === 'warn' ? console.warn : console.error\n\n if (typeof input === 'string') {\n log('[origin-check] rejected string input', {\n requestUrl: input,\n rejectedOrigin: rejectedOrigin ?? null,\n allowedOrigins: Array.from(allowedOrigins),\n })\n return\n }\n\n if (!input) {\n log('[origin-check] rejected empty input', {\n rejectedOrigin: rejectedOrigin ?? null,\n allowedOrigins: Array.from(allowedOrigins),\n })\n return\n }\n\n log('[origin-check] rejected request', {\n requestUrl: input.url,\n host: input.headers.get('host'),\n forwardedHost: input.headers.get('x-forwarded-host'),\n forwardedProto: input.headers.get('x-forwarded-proto'),\n derivedOrigins: Array.from(requestOrigins(input)),\n rejectedOrigin: rejectedOrigin ?? null,\n allowedOrigins: Array.from(allowedOrigins),\n })\n}\n\nfunction isLoopbackHostname(hostname: string): boolean {\n return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'\n}\n\nfunction isLoopbackOrigin(origin: string): boolean {\n try {\n return isLoopbackHostname(new URL(origin).hostname)\n } catch {\n return false\n }\n}\n\nfunction normalizeOriginPort(url: URL): string {\n if (url.port) return url.port\n if (url.protocol === 'https:') return '443'\n if (url.protocol === 'http:') return '80'\n return ''\n}\n\nfunction isEquivalentLoopbackOrigin(origin: string, allowedOrigin: string): boolean {\n try {\n const candidateUrl = new URL(origin)\n const allowedUrl = new URL(allowedOrigin)\n if (!isLoopbackHostname(candidateUrl.hostname) || !isLoopbackHostname(allowedUrl.hostname)) {\n return false\n }\n return normalizeOriginPort(candidateUrl) === normalizeOriginPort(allowedUrl)\n } catch {\n return false\n }\n}\n\nfunction shouldAllowLoopbackOrigin(origin: string, allowedOrigins: Set<string>, env: EnvLike): boolean {\n if (!isLoopbackOrigin(origin)) return false\n for (const allowedOrigin of allowedOrigins) {\n if (!isLoopbackOrigin(allowedOrigin)) continue\n if (env.NODE_ENV !== 'production') return true\n if (isEquivalentLoopbackOrigin(origin, allowedOrigin)) return true\n }\n return false\n}\n\nexport function assertAllowedAppOrigin(input: RequestInput, env: EnvLike = process.env): void {\n const allowedOrigins = readAllowedOrigins(env)\n if (allowedOrigins.size === 0) {\n if (env.NODE_ENV === 'production') {\n logOriginDebugContext(input, allowedOrigins)\n throw new AppOriginConfigurationError('APP_URL must be configured in production')\n }\n return\n }\n const { urlOrigin, headerOrigins } = readRequestOriginCandidates(input)\n const hasAllowedHeaderOrigin = Array.from(headerOrigins).some((origin) => allowedOrigins.has(origin))\n\n for (const origin of headerOrigins) {\n if (allowedOrigins.has(origin)) continue\n if (shouldAllowLoopbackOrigin(origin, allowedOrigins, env)) continue\n logOriginDebugContext(input, allowedOrigins, origin, 'warn', env.NODE_ENV)\n throw new AppOriginRejectedError('Request origin is not allowed')\n }\n\n if (!urlOrigin) return\n if (allowedOrigins.has(urlOrigin)) return\n if (shouldAllowLoopbackOrigin(urlOrigin, allowedOrigins, env)) return\n if (isLoopbackOrigin(urlOrigin) && hasAllowedHeaderOrigin) return\n\n logOriginDebugContext(input, allowedOrigins, urlOrigin, 'warn', env.NODE_ENV)\n throw new AppOriginRejectedError('Request origin is not allowed')\n}\n\nexport function resolveRequestOrigin(req: Request): string {\n const url = new URL(req.url)\n const proto = req.headers.get('x-forwarded-proto') || url.protocol.replace(':', '')\n const host = req.headers.get('x-forwarded-host') || req.headers.get('host') || url.host\n return `${proto}://${host}`\n}\n\nexport function getAppBaseUrl(req: Request): string {\n return (\n process.env.NEXT_PUBLIC_APP_URL ||\n process.env.APP_URL ||\n resolveRequestOrigin(req)\n )\n}\n\nexport function toAbsoluteUrl(req: Request, path: string): string {\n return new URL(path, getAppBaseUrl(req)).toString()\n}\n\nexport function getSecurityEmailBaseUrl(input?: RequestInput, env: EnvLike = process.env): string {\n const configuredAppUrl = normalizeBaseUrl(env.APP_URL)\n if (!configuredAppUrl) {\n if (env.NODE_ENV === 'production') {\n throw new AppOriginConfigurationError('APP_URL must be configured in production')\n }\n return DEFAULT_DEV_APP_URL\n }\n\n assertAllowedAppOrigin(input, env)\n return configuredAppUrl\n}\n\nexport function toSecurityEmailUrl(input: RequestInput, path: string, env: EnvLike = process.env): string {\n const base = getSecurityEmailBaseUrl(input, env)\n return `${base}/${path.replace(/^\\/+/, '')}`\n}\n\nexport function mapSecurityEmailUrlError(\n error: unknown,\n mapping: SecurityEmailUrlErrorMapping,\n): { status: number; body: { error: string } } | null {\n if (error instanceof AppOriginRejectedError) {\n return { status: 400, body: { error: 'Invalid request origin' } }\n }\n if (error instanceof AppOriginConfigurationError) {\n console.error(`[${mapping.scope}] APP_URL is required in production`)\n return { status: 500, body: { error: mapping.configMessage } }\n }\n return null\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,4 +1,4 @@
1
- const APP_VERSION = "0.5.1-develop.2855.9b058b7483";
1
+ const APP_VERSION = "0.5.1-develop.2860.07af3a6a9d";
2
2
  const appVersion = APP_VERSION;
3
3
  export {
4
4
  APP_VERSION,
@@ -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.5.1-develop.2855.9b058b7483'\nexport const appVersion = APP_VERSION\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.5.1-develop.2860.07af3a6a9d'\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.5.1-develop.2855.9b058b7483",
3
+ "version": "0.5.1-develop.2860.07af3a6a9d",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -92,7 +92,7 @@
92
92
  "@mikro-orm/core": "^7.0.10",
93
93
  "@mikro-orm/decorators": "^7.0.10",
94
94
  "@mikro-orm/postgresql": "^7.0.10",
95
- "@open-mercato/cache": "0.5.1-develop.2855.9b058b7483",
95
+ "@open-mercato/cache": "0.5.1-develop.2860.07af3a6a9d",
96
96
  "dotenv": "^17.4.2",
97
97
  "rate-limiter-flexible": "^11.0.1",
98
98
  "reflect-metadata": "^0.2.2",
@@ -34,6 +34,22 @@ describe('security email URL helpers', () => {
34
34
  expect(() => assertAllowedAppOrigin(request, env)).toThrow(AppOriginRejectedError)
35
35
  })
36
36
 
37
+ test('rejects an untrusted request URL even when forwarded host matches the configured app origin', () => {
38
+ const env = {
39
+ APP_URL: 'https://auth.openmercato.com',
40
+ NODE_ENV: 'production',
41
+ }
42
+ const request = new Request('https://evil.example/api/auth/reset', {
43
+ headers: {
44
+ host: 'auth.openmercato.com',
45
+ 'x-forwarded-host': 'auth.openmercato.com',
46
+ 'x-forwarded-proto': 'https',
47
+ },
48
+ })
49
+
50
+ expect(() => assertAllowedAppOrigin(request, env)).toThrow(AppOriginRejectedError)
51
+ })
52
+
37
53
  test('rejects a mismatched Host header even when the request URL origin is allowed', () => {
38
54
  const env = {
39
55
  APP_URL: 'https://app.example.com',
@@ -57,6 +73,22 @@ describe('security email URL helpers', () => {
57
73
  expect(() => assertAllowedAppOrigin(request, env)).not.toThrow()
58
74
  })
59
75
 
76
+ test('allows internal request URLs when forwarded host matches the configured app origin', () => {
77
+ const env = {
78
+ APP_URL: 'https://auth.openmercato.com',
79
+ NODE_ENV: 'production',
80
+ }
81
+ const request = new Request('https://localhost:9876/api/auth/reset', {
82
+ headers: {
83
+ host: 'auth.openmercato.com',
84
+ 'x-forwarded-host': 'auth.openmercato.com',
85
+ 'x-forwarded-proto': 'https',
86
+ },
87
+ })
88
+
89
+ expect(() => assertAllowedAppOrigin(request, env)).not.toThrow()
90
+ })
91
+
60
92
  test('allows loopback origin mismatches outside production', () => {
61
93
  const env = {
62
94
  APP_URL: 'http://localhost:3000',
@@ -0,0 +1,33 @@
1
+ import { readRateLimitConfig } from '../config'
2
+
3
+ describe('readRateLimitConfig', () => {
4
+ const originalEnv = { ...process.env }
5
+
6
+ afterEach(() => {
7
+ process.env = { ...originalEnv }
8
+ })
9
+
10
+ it('defaults to enabled when no env is set', () => {
11
+ delete process.env.RATE_LIMIT_ENABLED
12
+ delete process.env.OM_INTEGRATION_TEST
13
+ expect(readRateLimitConfig().enabled).toBe(true)
14
+ })
15
+
16
+ it('honors RATE_LIMIT_ENABLED=false', () => {
17
+ process.env.RATE_LIMIT_ENABLED = 'false'
18
+ delete process.env.OM_INTEGRATION_TEST
19
+ expect(readRateLimitConfig().enabled).toBe(false)
20
+ })
21
+
22
+ it('forces enabled=false under OM_INTEGRATION_TEST=true even when RATE_LIMIT_ENABLED=true', () => {
23
+ process.env.RATE_LIMIT_ENABLED = 'true'
24
+ process.env.OM_INTEGRATION_TEST = 'true'
25
+ expect(readRateLimitConfig().enabled).toBe(false)
26
+ })
27
+
28
+ it('ignores OM_INTEGRATION_TEST=false', () => {
29
+ process.env.RATE_LIMIT_ENABLED = 'true'
30
+ process.env.OM_INTEGRATION_TEST = 'false'
31
+ expect(readRateLimitConfig().enabled).toBe(true)
32
+ })
33
+ })
@@ -11,8 +11,18 @@ export function readRateLimitConfig(): RateLimitGlobalConfig {
11
11
 
12
12
  const trustProxyDepth = parsePositiveInt(process.env.RATE_LIMIT_TRUST_PROXY_DEPTH) ?? 1
13
13
 
14
+ // Integration test runs disable rate limiting globally so suites do not
15
+ // have to juggle per-endpoint bypass headers or reshape default caps.
16
+ // The targeted OM_TEST_MODE + OM_TEST_AUTH_RATE_LIMIT_MODE=opt-in escape
17
+ // hatch (checkAuthRateLimit) still works for suites that explicitly test
18
+ // rate-limit behavior.
19
+ const integrationTest = parseBooleanWithDefault(process.env.OM_INTEGRATION_TEST, false)
20
+ const enabled = integrationTest
21
+ ? false
22
+ : parseBooleanWithDefault(process.env.RATE_LIMIT_ENABLED, true)
23
+
14
24
  return {
15
- enabled: parseBooleanWithDefault(process.env.RATE_LIMIT_ENABLED, true),
25
+ enabled,
16
26
  strategy,
17
27
  keyPrefix: process.env.RATE_LIMIT_KEY_PREFIX ?? 'rl',
18
28
  redisUrl: process.env.REDIS_URL,
package/src/lib/url.ts CHANGED
@@ -77,32 +77,84 @@ function originFromHost(protocol: string, rawHost: string | null): string | null
77
77
  return normalizeOrigin(`${protocol}//${host}`)
78
78
  }
79
79
 
80
- function requestOrigins(input: RequestInput): Set<string> {
81
- const origins = new Set<string>()
82
- if (!input) return origins
80
+ type RequestOriginCandidates = {
81
+ urlOrigin: string | null
82
+ headerOrigins: Set<string>
83
+ }
84
+
85
+ function readRequestOriginCandidates(input: RequestInput): RequestOriginCandidates {
86
+ const candidates: RequestOriginCandidates = {
87
+ urlOrigin: null,
88
+ headerOrigins: new Set<string>(),
89
+ }
90
+ if (!input) return candidates
83
91
 
84
92
  const requestUrl = typeof input === 'string' ? input : input.url
85
93
  let parsedUrl: URL
86
94
  try {
87
95
  parsedUrl = new URL(requestUrl)
88
96
  } catch {
89
- return origins
97
+ return candidates
90
98
  }
91
99
 
92
- const urlOrigin = normalizeOrigin(parsedUrl.origin)
93
- if (urlOrigin) origins.add(urlOrigin)
94
-
95
- if (typeof input === 'string') return origins
100
+ candidates.urlOrigin = normalizeOrigin(parsedUrl.origin)
101
+ if (typeof input === 'string') return candidates
96
102
 
97
103
  const forwardedProto = readFirstHeaderValue(input.headers.get('x-forwarded-proto')) ?? parsedUrl.protocol.replace(/:$/, '')
98
104
  const protocol = forwardedProto.endsWith(':') ? forwardedProto : `${forwardedProto}:`
99
105
  const hostOrigin = originFromHost(protocol, input.headers.get('host'))
100
106
  const forwardedHostOrigin = originFromHost(protocol, input.headers.get('x-forwarded-host'))
101
- if (hostOrigin) origins.add(hostOrigin)
102
- if (forwardedHostOrigin) origins.add(forwardedHostOrigin)
107
+ if (hostOrigin) candidates.headerOrigins.add(hostOrigin)
108
+ if (forwardedHostOrigin) candidates.headerOrigins.add(forwardedHostOrigin)
109
+ return candidates
110
+ }
111
+
112
+ function requestOrigins(input: RequestInput): Set<string> {
113
+ const origins = new Set<string>()
114
+ const { urlOrigin, headerOrigins } = readRequestOriginCandidates(input)
115
+ if (urlOrigin) origins.add(urlOrigin)
116
+ for (const origin of headerOrigins) origins.add(origin)
103
117
  return origins
104
118
  }
105
119
 
120
+ function logOriginDebugContext(
121
+ input: RequestInput,
122
+ allowedOrigins: Set<string>,
123
+ rejectedOrigin?: string,
124
+ level: 'error' | 'warn' = 'error',
125
+ nodeEnv?: string,
126
+ ): void {
127
+ if (level === 'warn' && nodeEnv === 'test') return
128
+ const log = level === 'warn' ? console.warn : console.error
129
+
130
+ if (typeof input === 'string') {
131
+ log('[origin-check] rejected string input', {
132
+ requestUrl: input,
133
+ rejectedOrigin: rejectedOrigin ?? null,
134
+ allowedOrigins: Array.from(allowedOrigins),
135
+ })
136
+ return
137
+ }
138
+
139
+ if (!input) {
140
+ log('[origin-check] rejected empty input', {
141
+ rejectedOrigin: rejectedOrigin ?? null,
142
+ allowedOrigins: Array.from(allowedOrigins),
143
+ })
144
+ return
145
+ }
146
+
147
+ log('[origin-check] rejected request', {
148
+ requestUrl: input.url,
149
+ host: input.headers.get('host'),
150
+ forwardedHost: input.headers.get('x-forwarded-host'),
151
+ forwardedProto: input.headers.get('x-forwarded-proto'),
152
+ derivedOrigins: Array.from(requestOrigins(input)),
153
+ rejectedOrigin: rejectedOrigin ?? null,
154
+ allowedOrigins: Array.from(allowedOrigins),
155
+ })
156
+ }
157
+
106
158
  function isLoopbackHostname(hostname: string): boolean {
107
159
  return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
108
160
  }
@@ -149,19 +201,28 @@ export function assertAllowedAppOrigin(input: RequestInput, env: EnvLike = proce
149
201
  const allowedOrigins = readAllowedOrigins(env)
150
202
  if (allowedOrigins.size === 0) {
151
203
  if (env.NODE_ENV === 'production') {
204
+ logOriginDebugContext(input, allowedOrigins)
152
205
  throw new AppOriginConfigurationError('APP_URL must be configured in production')
153
206
  }
154
207
  return
155
208
  }
156
- const origins = requestOrigins(input)
157
- if (origins.size === 0) return
209
+ const { urlOrigin, headerOrigins } = readRequestOriginCandidates(input)
210
+ const hasAllowedHeaderOrigin = Array.from(headerOrigins).some((origin) => allowedOrigins.has(origin))
158
211
 
159
- for (const origin of origins) {
160
- if (!allowedOrigins.has(origin)) {
161
- if (shouldAllowLoopbackOrigin(origin, allowedOrigins, env)) continue
162
- throw new AppOriginRejectedError('Request origin is not allowed')
163
- }
212
+ for (const origin of headerOrigins) {
213
+ if (allowedOrigins.has(origin)) continue
214
+ if (shouldAllowLoopbackOrigin(origin, allowedOrigins, env)) continue
215
+ logOriginDebugContext(input, allowedOrigins, origin, 'warn', env.NODE_ENV)
216
+ throw new AppOriginRejectedError('Request origin is not allowed')
164
217
  }
218
+
219
+ if (!urlOrigin) return
220
+ if (allowedOrigins.has(urlOrigin)) return
221
+ if (shouldAllowLoopbackOrigin(urlOrigin, allowedOrigins, env)) return
222
+ if (isLoopbackOrigin(urlOrigin) && hasAllowedHeaderOrigin) return
223
+
224
+ logOriginDebugContext(input, allowedOrigins, urlOrigin, 'warn', env.NODE_ENV)
225
+ throw new AppOriginRejectedError('Request origin is not allowed')
165
226
  }
166
227
 
167
228
  export function resolveRequestOrigin(req: Request): string {