@open-mercato/shared 0.5.1-develop.2855.9b058b7483 → 0.5.1-develop.2856.35de414092
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 +60 -16
- package/dist/lib/url.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/__tests__/url.test.ts +32 -0
- package/src/lib/url.ts +78 -17
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
|
|
55
|
-
const
|
|
56
|
-
|
|
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
|
|
65
|
+
return candidates;
|
|
63
66
|
}
|
|
64
|
-
|
|
65
|
-
if (
|
|
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)
|
|
72
|
-
if (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
|
|
121
|
-
|
|
122
|
-
for (const origin of
|
|
123
|
-
if (
|
|
124
|
-
|
|
125
|
-
|
|
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);
|
package/dist/lib/url.js.map
CHANGED
|
@@ -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
|
|
5
|
-
"mappings": "AAAA,MAAM,sBAAsB;AAerB,MAAM,oCAAoC,MAAM;AAAA,EACrD,YAAY,SAAiB;AAC3B,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACd;AACF;AAEO,MAAM,+BAA+B,MAAM;AAAA,EAChD,YAAY,SAAiB;AAC3B,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACd;AACF;AAEA,SAAS,aAAa,KAAqC;AACzD,QAAM,QAAQ,KAAK,KAAK;AACxB,MAAI,CAAC,MAAO,QAAO;AACnB,MAAI;AACF,UAAM,MAAM,IAAI,IAAI,KAAK;AACzB,QAAI,OAAO;AACX,QAAI,SAAS;AACb,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,iBAAiB,KAAwC;AAChE,QAAM,MAAM,aAAa,GAAG;AAC5B,SAAO,MAAM,IAAI,SAAS,EAAE,QAAQ,OAAO,EAAE,IAAI;AACnD;AAEA,SAAS,gBAAgB,KAAwC;AAC/D,QAAM,MAAM,aAAa,GAAG;AAC5B,SAAO,MAAM,IAAI,SAAS;AAC5B;AAEA,SAAS,QAAQ,OAAqC;AACpD,UAAQ,SAAS,IACd,MAAM,GAAG,EACT,IAAI,CAAC,SAAS,KAAK,KAAK,CAAC,EACzB,OAAO,CAAC,SAAS,KAAK,SAAS,CAAC;AACrC;AAEA,SAAS,mBAAmB,KAA2B;AACrD,QAAM,UAAU,oBAAI,IAAY;AAChC,aAAW,OAAO,CAAC,IAAI,SAAS,IAAI,qBAAqB,GAAG,QAAQ,IAAI,mBAAmB,CAAC,GAAG;AAC7F,UAAM,SAAS,gBAAgB,GAAG;AAClC,QAAI,OAAQ,SAAQ,IAAI,MAAM;AAAA,EAChC;AACA,SAAO;AACT;AAEA,SAAS,qBAAqB,OAAqC;AACjE,QAAM,QAAQ,OAAO,MAAM,GAAG,EAAE,CAAC,GAAG,KAAK;AACzC,SAAO,SAAS,MAAM,SAAS,IAAI,QAAQ;AAC7C;AAEA,SAAS,eAAe,UAAkB,SAAuC;AAC/E,QAAM,OAAO,qBAAqB,OAAO;AACzC,MAAI,CAAC,KAAM,QAAO;AAClB,SAAO,gBAAgB,GAAG,QAAQ,KAAK,IAAI,EAAE;AAC/C;
|
|
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
|
}
|
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.5.1-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.5.1-develop.2856.35de414092'\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.
|
|
3
|
+
"version": "0.5.1-develop.2856.35de414092",
|
|
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.
|
|
95
|
+
"@open-mercato/cache": "0.5.1-develop.2856.35de414092",
|
|
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',
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
97
|
+
return candidates
|
|
90
98
|
}
|
|
91
99
|
|
|
92
|
-
|
|
93
|
-
if (
|
|
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)
|
|
102
|
-
if (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
|
|
157
|
-
|
|
209
|
+
const { urlOrigin, headerOrigins } = readRequestOriginCandidates(input)
|
|
210
|
+
const hasAllowedHeaderOrigin = Array.from(headerOrigins).some((origin) => allowedOrigins.has(origin))
|
|
158
211
|
|
|
159
|
-
for (const origin of
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
|
|
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 {
|