Skip to content

Commit 4f81938

Browse files
committed
Fix gateway CORS for DNS deployment
1 parent e841d45 commit 4f81938

4 files changed

Lines changed: 129 additions & 3 deletions

File tree

.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
JWT_SECRET=9fK2xPq7LmN4vR8sT1wZ6bH3cY5uJ0aQeD2nM7pL4x
22
JWT_EXPIRES=1h
33
GATEWAY_HTTPS_HOST=localhost
4+
GATEWAY_PUBLIC_HOSTNAME=localhost
5+
GATEWAY_ALLOWED_ORIGINS=
46
GATEWAY_HTTPS_HOST_PORT=443
57
GATEWAY_HTTP_HOST_PORT=8080
68
PORT=8080
79
HTTPS_PORT=8443
810
HTTP_REDIRECT_ENABLED=false
9-
GATEWAY_PUBLIC_HOSTNAME=localhost
1011
HTTPS_CERT_PATH=
1112
HTTPS_KEY_PATH=

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ services:
5050
- HTTP_REDIRECT_ENABLED=${HTTP_REDIRECT_ENABLED:-false}
5151
- GATEWAY_HTTPS_HOST=${GATEWAY_HTTPS_HOST:-localhost}
5252
- GATEWAY_PUBLIC_HOSTNAME=${GATEWAY_PUBLIC_HOSTNAME:-localhost}
53+
- GATEWAY_ALLOWED_ORIGINS=${GATEWAY_ALLOWED_ORIGINS:-}
5354
- GATEWAY_HTTPS_HOST_PORT=${GATEWAY_HTTPS_HOST_PORT:-443}
5455
- HTTPS_CERT_PATH=${HTTPS_CERT_PATH:-}
5556
- HTTPS_KEY_PATH=${HTTPS_KEY_PATH:-}

gateway/gateway-service.js

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,72 @@ function buildHttpsOrigin(hostname, httpsPort) {
201201
return `https://${normalizedHostname}${portSuffix}`;
202202
}
203203

204+
function parseAllowedOrigins(value) {
205+
const normalized = normalizeOptionalString(value);
206+
if (!normalized) {
207+
return [];
208+
}
209+
210+
return normalized
211+
.split(',')
212+
.map((origin) => origin.trim())
213+
.filter((origin) => origin.length > 0);
214+
}
215+
216+
function parseOriginUrl(value) {
217+
try {
218+
return new URL(value);
219+
} catch {
220+
return null;
221+
}
222+
}
223+
224+
function normalizeHostname(value) {
225+
const normalized = normalizeOptionalString(value);
226+
if (!normalized) {
227+
return null;
228+
}
229+
230+
const asUrl = parseOriginUrl(normalized.includes('://') ? normalized : `https://${normalized}`);
231+
return asUrl?.hostname ?? null;
232+
}
233+
234+
function isOriginAllowedByHostname(origin, hostnames) {
235+
const originUrl = parseOriginUrl(origin);
236+
if (!originUrl) {
237+
return false;
238+
}
239+
240+
return hostnames
241+
.map(normalizeHostname)
242+
.filter(Boolean)
243+
.some((hostname) => originUrl.hostname === hostname);
244+
}
245+
246+
function isInternalApiOriginAllowed(origin, env = process.env) {
247+
if (!origin) {
248+
return true;
249+
}
250+
251+
const allowedOrigins = parseAllowedOrigins(env.GATEWAY_ALLOWED_ORIGINS);
252+
if (allowedOrigins.includes(origin)) {
253+
return true;
254+
}
255+
256+
return isOriginAllowedByHostname(origin, [
257+
env.GATEWAY_PUBLIC_HOSTNAME,
258+
env.GATEWAY_HTTPS_HOST,
259+
]);
260+
}
261+
262+
function handleCorsError(error, _req, res, next) {
263+
if (!error || error.message !== 'Not allowed by CORS for internal API') {
264+
return next(error);
265+
}
266+
267+
return res.status(403).json({ message: error.message });
268+
}
269+
204270
function sanitizeRedirectPath(requestPath = '/') {
205271
if (typeof requestPath !== 'string' || requestPath.trim().length === 0 || !requestPath.startsWith('/')) {
206272
return '/';
@@ -963,18 +1029,18 @@ function createApp({
9631029
app.use('/external', createExternalApiRouter({ env, fetchImpl, spec }));
9641030

9651031
const serviceUrls = buildServiceUrls(env);
966-
const publicHostname = env.GATEWAY_PUBLIC_HOSTNAME || 'localhost';
9671032

9681033
// Stricter CORS for the internal API
9691034
app.use('/api', cors({
9701035
origin: (origin, callback) => {
971-
if (!origin || origin.includes(publicHostname)) {
1036+
if (isInternalApiOriginAllowed(origin, env)) {
9721037
callback(null, true);
9731038
} else {
9741039
callback(new Error('Not allowed by CORS for internal API'));
9751040
}
9761041
}
9771042
}));
1043+
app.use('/api', handleCorsError);
9781044

9791045
app.use('/api', createInternalApiRouter({ serviceUrls, fetchImpl }));
9801046

@@ -1061,6 +1127,7 @@ module.exports = {
10611127
STRATEGY_TO_BOT_ID,
10621128
applyBotMoveToYen,
10631129
buildDocsHtml,
1130+
handleCorsError,
10641131
buildProxy,
10651132
buildRedirectDestination,
10661133
buildHttpsOrigin,
@@ -1070,8 +1137,10 @@ module.exports = {
10701137
getRedirectHostname,
10711138
getProxyRoutes,
10721139
getTlsConfig,
1140+
isInternalApiOriginAllowed,
10731141
isDirectExecution,
10741142
loadTlsOptions,
1143+
parseAllowedOrigins,
10751144
sanitizeRedirectPath,
10761145
pickPlayBotId,
10771146
parseBoolean,

gateway/tests/services/gateway-service.test.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ const {
1616
createRedirectApp,
1717
getRedirectHostname,
1818
getTlsConfig,
19+
isInternalApiOriginAllowed,
1920
loadTlsOptions,
21+
parseAllowedOrigins,
2022
sanitizeRedirectPath,
2123
parseBoolean,
2224
start,
@@ -169,6 +171,59 @@ test('createApp sets expected proxy configuration for each route', () => {
169171
assert.equal(webappProxy.pathRewrite, undefined);
170172
});
171173

174+
test('parseAllowedOrigins normalizes comma-separated CORS origins', () => {
175+
assert.deepEqual(parseAllowedOrigins(' https://app.example.com,https://admin.example.com '), [
176+
'https://app.example.com',
177+
'https://admin.example.com',
178+
]);
179+
assert.deepEqual(parseAllowedOrigins(''), []);
180+
});
181+
182+
test('isInternalApiOriginAllowed accepts configured public hosts and explicit origins', () => {
183+
assert.equal(isInternalApiOriginAllowed(undefined, {}), true);
184+
assert.equal(
185+
isInternalApiOriginAllowed('https://yovies4b.duckdns.org', {
186+
GATEWAY_PUBLIC_HOSTNAME: 'yovies4b.duckdns.org',
187+
}),
188+
true,
189+
);
190+
assert.equal(
191+
isInternalApiOriginAllowed('https://preview.example.com', {
192+
GATEWAY_PUBLIC_HOSTNAME: 'yovies4b.duckdns.org',
193+
GATEWAY_ALLOWED_ORIGINS: 'https://preview.example.com',
194+
}),
195+
true,
196+
);
197+
assert.equal(
198+
isInternalApiOriginAllowed('https://evil-yovies4b.duckdns.org', {
199+
GATEWAY_PUBLIC_HOSTNAME: 'yovies4b.duckdns.org',
200+
}),
201+
false,
202+
);
203+
});
204+
205+
test('internal API returns 403 instead of 500 when CORS rejects the origin', async () => {
206+
const { app } = createApp({
207+
proxyFactory: noopProxyFactory,
208+
env: { GATEWAY_PUBLIC_HOSTNAME: 'yovies4b.duckdns.org' },
209+
});
210+
211+
await withServer(app, async (baseUrl) => {
212+
const response = await fetch(`${baseUrl}/api/v1/matchmaking/enqueue`, {
213+
method: 'POST',
214+
headers: {
215+
Origin: 'https://wrong.example.com',
216+
'Content-Type': 'application/json',
217+
},
218+
body: JSON.stringify({ size: 7 }),
219+
});
220+
const body = await response.json();
221+
222+
assert.equal(response.status, 403);
223+
assert.equal(body.message, 'Not allowed by CORS for internal API');
224+
});
225+
});
226+
172227
// buildProxy returns a 502 response when upstream fails.
173228
test('buildProxy returns a 502 response when upstream fails', () => {
174229
let capturedConfig;

0 commit comments

Comments
 (0)