@open-mercato/shared 0.9.1-develop.7548.1.ee60458c45 → 0.9.1-develop.7553.1.8dbab05b5a

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/url.js CHANGED
@@ -199,6 +199,29 @@ function toSecurityEmailUrl(input, path, env = process.env) {
199
199
  const base = getSecurityEmailBaseUrl(input, env);
200
200
  return `${base}/${path.replace(/^\/+/, "")}`;
201
201
  }
202
+ function isCrossSiteBrowserRequest(req, env = process.env) {
203
+ const rawFetchSite = req.headers.get("sec-fetch-site");
204
+ const fetchSite = rawFetchSite?.trim().toLowerCase() ?? null;
205
+ if (fetchSite === "cross-site") return true;
206
+ if (fetchSite === "same-origin" || fetchSite === "none") return false;
207
+ const rawOrigin = req.headers.get("origin");
208
+ if (rawOrigin === null) return rawFetchSite !== null;
209
+ const origin = normalizeOrigin(rawOrigin);
210
+ if (!origin) return true;
211
+ const allowedOrigins = readAllowedOrigins(env);
212
+ if (allowedOrigins.has(origin)) return false;
213
+ if (shouldAllowLoopbackOrigin(origin, allowedOrigins, env)) return false;
214
+ const forwardedProto = readFirstHeaderValue(req.headers.get("x-forwarded-proto"));
215
+ const protocol = forwardedProto ? forwardedProto.endsWith(":") ? forwardedProto : `${forwardedProto}:` : new URL(origin).protocol;
216
+ const host = req.headers.get("host");
217
+ const forwardedHost = req.headers.get("x-forwarded-host");
218
+ if (originFromHost(protocol, forwardedHost) === origin) return false;
219
+ if (originFromHost(protocol, host) === origin) return false;
220
+ if (env.NODE_ENV !== "test") {
221
+ logger.warn("Cross-site request rejected", { origin, host, forwardedHost });
222
+ }
223
+ return true;
224
+ }
202
225
  function mapSecurityEmailUrlError(error, mapping) {
203
226
  if (error instanceof AppOriginRejectedError) {
204
227
  return { status: 400, body: { error: "Invalid request origin" } };
@@ -215,6 +238,7 @@ export {
215
238
  assertAllowedAppOrigin,
216
239
  getAppBaseUrl,
217
240
  getSecurityEmailBaseUrl,
241
+ isCrossSiteBrowserRequest,
218
242
  mapSecurityEmailUrlError,
219
243
  resolveRequestOrigin,
220
244
  toAbsoluteUrl,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/url.ts"],
4
- "sourcesContent": ["import { createLogger } from './logger'\n\nconst logger = createLogger('shared').child({ component: 'origin-check' })\n\nconst 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 = (msg: string, fields: Record<string, unknown>) =>\n level === 'warn' ? logger.warn(msg, fields) : logger.error(msg, fields)\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 logger.error('APP_URL is required in production', { scope: mapping.scope })\n return { status: 500, body: { error: mapping.configMessage } }\n }\n return null\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { createLogger } from './logger'\n\nconst logger = createLogger('shared').child({ component: 'origin-check' })\n\nconst 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 = (msg: string, fields: Record<string, unknown>) =>\n level === 'warn' ? logger.warn(msg, fields) : logger.error(msg, fields)\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 isCrossSiteBrowserRequest(req: Request, env: EnvLike = process.env): boolean {\n const rawFetchSite = req.headers.get('sec-fetch-site')\n const fetchSite = rawFetchSite?.trim().toLowerCase() ?? null\n if (fetchSite === 'cross-site') return true\n if (fetchSite === 'same-origin' || fetchSite === 'none') return false\n\n const rawOrigin = req.headers.get('origin')\n if (rawOrigin === null) return rawFetchSite !== null\n const origin = normalizeOrigin(rawOrigin)\n if (!origin) return true\n\n const allowedOrigins = readAllowedOrigins(env)\n if (allowedOrigins.has(origin)) return false\n if (shouldAllowLoopbackOrigin(origin, allowedOrigins, env)) return false\n\n const forwardedProto = readFirstHeaderValue(req.headers.get('x-forwarded-proto'))\n const protocol = forwardedProto\n ? (forwardedProto.endsWith(':') ? forwardedProto : `${forwardedProto}:`)\n : new URL(origin).protocol\n const host = req.headers.get('host')\n const forwardedHost = req.headers.get('x-forwarded-host')\n if (originFromHost(protocol, forwardedHost) === origin) return false\n if (originFromHost(protocol, host) === origin) return false\n\n if (env.NODE_ENV !== 'test') {\n logger.warn('Cross-site request rejected', { origin, host, forwardedHost })\n }\n return true\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 logger.error('APP_URL is required in production', { scope: mapping.scope })\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.9.1-develop.7548.1.ee60458c45";
1
+ const APP_VERSION = "0.9.1-develop.7553.1.8dbab05b5a";
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.9.1-develop.7548.1.ee60458c45';\nexport const appVersion = APP_VERSION;\n"],
4
+ "sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.9.1-develop.7553.1.8dbab05b5a';\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.9.1-develop.7548.1.ee60458c45",
3
+ "version": "0.9.1-develop.7553.1.8dbab05b5a",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -121,7 +121,7 @@
121
121
  "@mikro-orm/core": "^7.1.14",
122
122
  "@mikro-orm/decorators": "^7.1.14",
123
123
  "@mikro-orm/postgresql": "^7.1.14",
124
- "@open-mercato/cache": "0.9.1-develop.7548.1.ee60458c45",
124
+ "@open-mercato/cache": "0.9.1-develop.7553.1.8dbab05b5a",
125
125
  "@types/html-to-text": "^9.0.4",
126
126
  "@types/sanitize-html": "^2.16.1",
127
127
  "dotenv": "^17.4.2",
@@ -0,0 +1,140 @@
1
+ import { isCrossSiteBrowserRequest } from '../url'
2
+
3
+ function req(headers: Record<string, string>, url = 'https://app.example.com/api/auth/login') {
4
+ return new Request(url, { method: 'POST', headers })
5
+ }
6
+
7
+ const configured = { APP_URL: 'https://app.example.com', NODE_ENV: 'production' }
8
+ const unconfiguredDev = { NODE_ENV: 'development' }
9
+
10
+ describe('isCrossSiteBrowserRequest', () => {
11
+ describe('non-browser callers (no Origin, no Sec-Fetch-Site) are never cross-site', () => {
12
+ test.each([
13
+ ['configured', configured],
14
+ ['unconfigured dev', unconfiguredDev],
15
+ ])('%s', (_label, env) => {
16
+ expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com' }), env)).toBe(false)
17
+ })
18
+ })
19
+
20
+ describe('Fetch Metadata is authoritative when present', () => {
21
+ test('cross-site is rejected regardless of Origin', () => {
22
+ expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', 'sec-fetch-site': 'cross-site' }), configured)).toBe(true)
23
+ expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', 'sec-fetch-site': 'cross-site', origin: 'https://app.example.com' }), configured)).toBe(true)
24
+ })
25
+
26
+ test('same-origin and none are accepted even when the proxy hides the public host', () => {
27
+ const internal = 'http://app-internal:3000/api/auth/login'
28
+ expect(isCrossSiteBrowserRequest(req({ host: 'app-internal:3000', origin: 'https://public.example.net', 'sec-fetch-site': 'same-origin' }, internal), {})).toBe(false)
29
+ expect(isCrossSiteBrowserRequest(req({ host: 'app-internal:3000', 'sec-fetch-site': 'none' }, internal), {})).toBe(false)
30
+ })
31
+
32
+ test('same-site falls back to the Origin allowlist', () => {
33
+ expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', origin: 'https://blog.example.com', 'sec-fetch-site': 'same-site' }), configured)).toBe(true)
34
+ expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', origin: 'https://ops.example.com', 'sec-fetch-site': 'same-site' }), {
35
+ ...configured,
36
+ APP_ALLOWED_ORIGINS: 'https://ops.example.com',
37
+ })).toBe(false)
38
+ })
39
+
40
+ test('an unknown Sec-Fetch-Site token falls back to the Origin check', () => {
41
+ expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', origin: 'https://evil.example', 'sec-fetch-site': 'bogus' }), configured)).toBe(true)
42
+ expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', origin: 'https://app.example.com', 'sec-fetch-site': 'bogus' }), configured)).toBe(false)
43
+ })
44
+ })
45
+
46
+ describe('Origin check (browsers without Fetch Metadata)', () => {
47
+ test('Origin: null is cross-site', () => {
48
+ expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', origin: 'null' }), configured)).toBe(true)
49
+ })
50
+
51
+ test('a malformed Origin is cross-site', () => {
52
+ expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', origin: 'not a url' }), configured)).toBe(true)
53
+ })
54
+
55
+ test('a foreign Origin is cross-site', () => {
56
+ expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', origin: 'https://evil.example' }), configured)).toBe(true)
57
+ })
58
+
59
+ test('Origin matching a configured origin (APP_URL / NEXT_PUBLIC_APP_URL / APP_ALLOWED_ORIGINS) is accepted', () => {
60
+ const internal = 'http://app-internal:3000/api/auth/login'
61
+ expect(isCrossSiteBrowserRequest(req({ host: 'app-internal:3000', origin: 'https://app.example.com' }, internal), configured)).toBe(false)
62
+ expect(isCrossSiteBrowserRequest(req({ host: 'app-internal:3000', origin: 'https://admin.example.com' }, internal), {
63
+ NEXT_PUBLIC_APP_URL: 'https://admin.example.com',
64
+ })).toBe(false)
65
+ })
66
+
67
+ test('Origin matching the request Host authority is accepted (portal custom domains, unconfigured dev)', () => {
68
+ expect(isCrossSiteBrowserRequest(req({ host: 'shop.acme.test', origin: 'https://shop.acme.test' }, 'https://shop.acme.test/api/customer_accounts/login'), configured)).toBe(false)
69
+ expect(isCrossSiteBrowserRequest(req({ host: 'localhost:3000', origin: 'http://localhost:3000' }, 'http://localhost:3000/api/auth/login'), unconfiguredDev)).toBe(false)
70
+ })
71
+
72
+ test('Origin matching X-Forwarded-Host is accepted even when the proxy did not forward the scheme', () => {
73
+ expect(isCrossSiteBrowserRequest(req({
74
+ host: 'app-internal:3000',
75
+ 'x-forwarded-host': 'admin.example.com',
76
+ origin: 'https://admin.example.com',
77
+ }, 'http://app-internal:3000/api/auth/login'), {})).toBe(false)
78
+ })
79
+
80
+ test('a different port on the same host is cross-origin', () => {
81
+ expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', origin: 'https://app.example.com:8443' }), configured)).toBe(true)
82
+ })
83
+
84
+ test('a Host carrying the default port matches the Origin', () => {
85
+ expect(isCrossSiteBrowserRequest(req({
86
+ host: 'app.example.com:443',
87
+ 'x-forwarded-proto': 'https',
88
+ origin: 'https://app.example.com',
89
+ }, 'http://app.example.com:443/api/auth/login'), {})).toBe(false)
90
+ expect(isCrossSiteBrowserRequest(req({
91
+ host: 'app.example.com:443',
92
+ origin: 'https://app.example.com',
93
+ }, 'http://app.example.com:443/api/auth/login'), {})).toBe(false)
94
+ })
95
+
96
+ test('an uppercase Host matches the Origin', () => {
97
+ expect(isCrossSiteBrowserRequest(req({ host: 'APP.Example.COM', origin: 'https://app.example.com' }), {})).toBe(false)
98
+ })
99
+
100
+ test('an IPv6 loopback Host matches the Origin in dev', () => {
101
+ expect(isCrossSiteBrowserRequest(req({ host: '[::1]:3000', origin: 'http://[::1]:3000' }, 'http://[::1]:3000/api/auth/login'), unconfiguredDev)).toBe(false)
102
+ })
103
+
104
+ test('only the first X-Forwarded-Host value is used', () => {
105
+ const internal = 'http://app-internal:3000/api/auth/login'
106
+ expect(isCrossSiteBrowserRequest(req({
107
+ host: 'app-internal:3000',
108
+ 'x-forwarded-host': 'admin.example.com, evil.example',
109
+ origin: 'https://admin.example.com',
110
+ }, internal), {})).toBe(false)
111
+ expect(isCrossSiteBrowserRequest(req({
112
+ host: 'app-internal:3000',
113
+ 'x-forwarded-host': 'admin.example.com, evil.example',
114
+ origin: 'https://evil.example',
115
+ }, internal), {})).toBe(true)
116
+ })
117
+
118
+ test('a forwarded scheme that differs from the Origin scheme is cross-origin', () => {
119
+ expect(isCrossSiteBrowserRequest(req({
120
+ host: 'app.example.com',
121
+ 'x-forwarded-proto': 'https',
122
+ origin: 'http://app.example.com',
123
+ }), {})).toBe(true)
124
+ })
125
+ })
126
+
127
+ describe('Fetch Metadata without Origin', () => {
128
+ test('same-site without Origin is cross-site', () => {
129
+ expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', 'sec-fetch-site': 'same-site' }), configured)).toBe(true)
130
+ })
131
+
132
+ test('an unknown token without Origin is cross-site', () => {
133
+ expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', 'sec-fetch-site': 'bogus' }), configured)).toBe(true)
134
+ })
135
+
136
+ test('the Sec-Fetch-Site value is compared case- and whitespace-insensitively', () => {
137
+ expect(isCrossSiteBrowserRequest(req({ host: 'app.example.com', origin: 'https://app.example.com', 'sec-fetch-site': ' Cross-Site ' }), configured)).toBe(true)
138
+ })
139
+ })
140
+ })
package/src/lib/url.ts CHANGED
@@ -267,6 +267,36 @@ export function toSecurityEmailUrl(input: RequestInput, path: string, env: EnvLi
267
267
  return `${base}/${path.replace(/^\/+/, '')}`
268
268
  }
269
269
 
270
+ export function isCrossSiteBrowserRequest(req: Request, env: EnvLike = process.env): boolean {
271
+ const rawFetchSite = req.headers.get('sec-fetch-site')
272
+ const fetchSite = rawFetchSite?.trim().toLowerCase() ?? null
273
+ if (fetchSite === 'cross-site') return true
274
+ if (fetchSite === 'same-origin' || fetchSite === 'none') return false
275
+
276
+ const rawOrigin = req.headers.get('origin')
277
+ if (rawOrigin === null) return rawFetchSite !== null
278
+ const origin = normalizeOrigin(rawOrigin)
279
+ if (!origin) return true
280
+
281
+ const allowedOrigins = readAllowedOrigins(env)
282
+ if (allowedOrigins.has(origin)) return false
283
+ if (shouldAllowLoopbackOrigin(origin, allowedOrigins, env)) return false
284
+
285
+ const forwardedProto = readFirstHeaderValue(req.headers.get('x-forwarded-proto'))
286
+ const protocol = forwardedProto
287
+ ? (forwardedProto.endsWith(':') ? forwardedProto : `${forwardedProto}:`)
288
+ : new URL(origin).protocol
289
+ const host = req.headers.get('host')
290
+ const forwardedHost = req.headers.get('x-forwarded-host')
291
+ if (originFromHost(protocol, forwardedHost) === origin) return false
292
+ if (originFromHost(protocol, host) === origin) return false
293
+
294
+ if (env.NODE_ENV !== 'test') {
295
+ logger.warn('Cross-site request rejected', { origin, host, forwardedHost })
296
+ }
297
+ return true
298
+ }
299
+
270
300
  export function mapSecurityEmailUrlError(
271
301
  error: unknown,
272
302
  mapping: SecurityEmailUrlErrorMapping,