Skip to content

Commit 8b950a0

Browse files
authored
feat(server): Use origins option to configure Lobby API CORS (#955)
* feat: Use server `origins` option to configure Lobby API CORS * chore(deps): Update `@koa/cors` * test(server): Add tests for Koa app CORS config * feat(server): Add `apiOrigins` option to override `origins` for Lobby API
1 parent 2b1d013 commit 8b950a0

6 files changed

Lines changed: 132 additions & 16 deletions

File tree

docs/documentation/api/Server.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ A config object with the following options:
5656

5757
7. `authenticateCredentials` (_function_): an optional function that tests if a player’s move is made with the correct credentials when using the default socket.io transport implementation.
5858

59+
8. `apiOrigins` (_array_): a list of allowed origins for requests to the Lobby API. Defaults
60+
to the value provided as the `origins` option (which also applies to the socket transport).
61+
5962
#### Returns
6063

6164
An object that contains:

package-lock.json

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@
9393
"@types/enzyme": "^3.10.5",
9494
"@types/jest": "^24.0.0",
9595
"@types/koa-router": "^7.4.0",
96-
"@types/koa__cors": "^3.0.1",
96+
"@types/koa__cors": "^3.0.3",
9797
"@types/node": "^14.0.24",
9898
"@types/react": "^16.9.36",
9999
"@types/react-dom": "^16.9.8",
@@ -148,7 +148,7 @@
148148
"typescript": "^3.8.2"
149149
},
150150
"dependencies": {
151-
"@koa/cors": "^2.2.1",
151+
"@koa/cors": "^3.1.0",
152152
"@types/koa": "^2.11.3",
153153
"flatted": "^0.2.3",
154154
"immer": "^8.0.1",

src/server/api.test.ts

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { createRouter, configureApp } from './api';
1414
import { ProcessGameConfig } from '../core/game';
1515
import { Auth } from './auth';
1616
import * as StorageAPI from './db/base';
17+
import { Origins } from './cors';
1718
import type { Game, Server } from '../types';
1819

1920
jest.setTimeout(2000000000);
@@ -72,13 +73,19 @@ class AsyncStorage extends StorageAPI.Async {
7273
describe('.createRouter', () => {
7374
function addApiToServer({
7475
app,
76+
origins,
7577
...args
76-
}: { app: Server.App } & Parameters<typeof createRouter>[0]) {
78+
}: {
79+
app: Server.App;
80+
origins?: Parameters<typeof configureApp>[2];
81+
} & Parameters<typeof createRouter>[0]) {
7782
const router = createRouter(args);
78-
configureApp(app, router);
83+
configureApp(app, router, origins);
7984
}
8085

81-
function createApiServer(args: Parameters<typeof createRouter>[0]) {
86+
function createApiServer(
87+
args: Omit<Parameters<typeof addApiToServer>[0], 'app'>
88+
) {
8289
const app: Server.App = new Koa();
8390
addApiToServer({ app, ...args });
8491
return app;
@@ -1511,4 +1518,70 @@ describe('.createRouter', () => {
15111518
expect(server.use.mock.calls.length).toBeGreaterThan(1);
15121519
});
15131520
});
1521+
1522+
describe('cors', () => {
1523+
const auth = new Auth();
1524+
const games: Game[] = [];
1525+
const db = new AsyncStorage();
1526+
1527+
describe('no allowed origins', () => {
1528+
const app = createApiServer({ auth, games, db, origins: false });
1529+
1530+
test('does not allow CORS', async () => {
1531+
const { res } = await request(app.callback())
1532+
.get('/games')
1533+
.set('Origin', 'https://www.example.com')
1534+
.expect('Vary', 'Origin');
1535+
expect(res.headers).not.toHaveProperty('access-control-allow-origin');
1536+
expect(res.headers).not.toHaveProperty('Access-Control-Allow-Origin');
1537+
});
1538+
});
1539+
1540+
describe('single allowed origin', () => {
1541+
const origin = 'https://www.example.com';
1542+
const app = createApiServer({ auth, games, db, origins: origin });
1543+
1544+
test('disallows non-matching origin', async () => {
1545+
const { res } = await request(app.callback())
1546+
.get('/games')
1547+
.set('Origin', 'https://www.other.com')
1548+
.expect('Vary', 'Origin');
1549+
expect(res.headers).not.toHaveProperty('access-control-allow-origin');
1550+
expect(res.headers).not.toHaveProperty('Access-Control-Allow-Origin');
1551+
});
1552+
1553+
// eslint-disable-next-line jest/expect-expect
1554+
test('allows matching origin', async () => {
1555+
await request(app.callback())
1556+
.get('/games')
1557+
.set('Origin', origin)
1558+
.expect('Vary', 'Origin')
1559+
.expect('Access-Control-Allow-Origin', origin);
1560+
});
1561+
});
1562+
1563+
describe('multiple allowed origins', () => {
1564+
const origins = [Origins.LOCALHOST, 'https://www.example.com'];
1565+
const app = createApiServer({ auth, games, db, origins });
1566+
1567+
test('disallows non-matching origin', async () => {
1568+
const { res } = await request(app.callback())
1569+
.get('/games')
1570+
.set('Origin', 'https://www.other.com')
1571+
.expect('Vary', 'Origin');
1572+
expect(res.headers).not.toHaveProperty('access-control-allow-origin');
1573+
expect(res.headers).not.toHaveProperty('Access-Control-Allow-Origin');
1574+
});
1575+
1576+
// eslint-disable-next-line jest/expect-expect
1577+
test('allows matching origin', async () => {
1578+
const origin = 'http://localhost:5000';
1579+
await request(app.callback())
1580+
.get('/games')
1581+
.set('Origin', origin)
1582+
.expect('Vary', 'Origin')
1583+
.expect('Access-Control-Allow-Origin', origin);
1584+
});
1585+
});
1586+
});
15141587
});

src/server/api.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import Router from 'koa-router';
1111
import koaBody from 'koa-body';
1212
import { nanoid } from 'nanoid';
1313
import cors from '@koa/cors';
14+
import type IOTypes from 'socket.io';
1415
import { createMatch } from './util';
1516
import type { Auth } from './auth';
1617
import type { Server, LobbyAPI, Game, StorageAPI } from '../types';
@@ -446,9 +447,18 @@ export const createRouter = ({
446447

447448
export const configureApp = (
448449
app: Server.App,
449-
router: Router<any, Server.AppCtx>
450+
router: Router<any, Server.AppCtx>,
451+
origins: IOTypes.ServerOptions['cors']['origin']
450452
): void => {
451-
app.use(cors());
453+
app.use(
454+
cors({
455+
// Set Access-Control-Allow-Origin header for allowed origins.
456+
origin: (ctx) => {
457+
const origin = ctx.get('Origin');
458+
return isOriginAllowed(origin, origins) ? origin : '';
459+
},
460+
})
461+
);
452462

453463
// If API_SECRET is set, then require that requests set an
454464
// api-secret header that is set to the same value.
@@ -465,3 +475,30 @@ export const configureApp = (
465475

466476
app.use(router.routes()).use(router.allowedMethods());
467477
};
478+
479+
/**
480+
* Check if a request’s origin header is allowed for CORS.
481+
* Adapted from `cors` package: https://github.com/expressjs/cors
482+
* @param origin Request origin to test.
483+
* @param allowedOrigin Origin(s) that are allowed to connect via CORS.
484+
* @returns `true` if the origin matched at least one of the allowed origins.
485+
*/
486+
function isOriginAllowed(
487+
origin: string,
488+
allowedOrigin: IOTypes.ServerOptions['cors']['origin']
489+
): boolean {
490+
if (Array.isArray(allowedOrigin)) {
491+
for (const entry of allowedOrigin) {
492+
if (isOriginAllowed(origin, entry)) {
493+
return true;
494+
}
495+
}
496+
return false;
497+
} else if (typeof allowedOrigin === 'string') {
498+
return origin === allowedOrigin;
499+
} else if (allowedOrigin instanceof RegExp) {
500+
return allowedOrigin.test(origin);
501+
} else {
502+
return !!allowedOrigin;
503+
}
504+
}

src/server/index.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export const getPortFromServer = (
5959
interface ServerOpts {
6060
games: Game[];
6161
origins?: IOTypes.ServerOptions['cors']['origin'];
62+
apiOrigins?: IOTypes.ServerOptions['cors']['origin'];
6263
db?: StorageAPI.Async | StorageAPI.Sync;
6364
transport?: SocketIO;
6465
uuid?: () => string;
@@ -74,7 +75,8 @@ interface ServerOpts {
7475
* @param db - The interface with the database.
7576
* @param transport - The interface with the clients.
7677
* @param authenticateCredentials - Function to test player credentials.
77-
* @param origins - Allowed origins to use this server, i.e. [http://localhost:300]
78+
* @param origins - Allowed origins to use this server, e.g. `['http://localhost:3000']`.
79+
* @param apiOrigins - Allowed origins to use the Lobby API, defaults to `origins`.
7880
* @param generateCredentials - Method for API to generate player credentials.
7981
* @param https - HTTPS configuration options passed through to the TLS module.
8082
* @param lobbyConfig - Configuration options for the Lobby API server.
@@ -86,6 +88,7 @@ export function Server({
8688
https,
8789
uuid,
8890
origins,
91+
apiOrigins = origins,
8992
generateCredentials = uuid,
9093
authenticateCredentials,
9194
}: ServerOpts) {
@@ -133,13 +136,13 @@ export function Server({
133136
const lobbyConfig = serverRunConfig.lobbyConfig;
134137
let apiServer: KoaServer | undefined;
135138
if (!lobbyConfig || !lobbyConfig.apiPort) {
136-
configureApp(app, router);
139+
configureApp(app, router, apiOrigins);
137140
} else {
138141
// Run API in a separate Koa app.
139142
const api: ServerTypes.App = new Koa();
140143
api.context.db = db;
141144
api.context.auth = auth;
142-
configureApp(api, router);
145+
configureApp(api, router, apiOrigins);
143146
await new Promise((resolve) => {
144147
apiServer = api.listen(lobbyConfig.apiPort, resolve);
145148
});

0 commit comments

Comments
 (0)