Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement support for CORS allow-list #357

Merged
merged 8 commits into from
Dec 30, 2024
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Note that any values set in a configuration file take precedence over values set
| name | required | default | description |
|--------------------------------------|----------|---------------|-----------------------------------------------------------|
| **PORT** | no | `3000` | Port the HTTP server should listen on |
| **ENABLE_CORS_ANY_ORIGIN** | no | `true` | Allows requests from any domain to access the REST API endpoints |
| **CORS_ALLOWED_ORIGINS** | no | `*` | Configures CORS policy. Accepts a comma-separated list of allowed domains. (`*` allows all domains; `disable` disables CORS entirely.) |
| **NODE_ENV** | no | `development` | Environment that the app is running in |
| **DATABASE_URL** | yes | none | URL of the MongoDB server |
| **AUTHN_MECHANISM** | no | `anonymous` | Mechanism to use for authenticating users |
Expand All @@ -89,7 +89,7 @@ If the `JSON_CONFIG_PATH` environment variable is set, the app will also read co
| name | type | corresponding environment variable |
|-------------------------------------|----------|------------------------------------|
| **server.port** | int | PORT |
| **server.enableCorsAnyOrigin** | boolean | ENABLE_CORS_ANY_ORIGIN |
| **server.corsAllowedOrigins** | boolean | CORS_ALLOWED_ORIGINS |
| **app.env** | string | NODE_ENV |
| **database.url** | string | DATABASE_URL |
| **collectionIndex.defaultInterval** | int | DEFAULT_INTERVAL |
Expand Down
67 changes: 62 additions & 5 deletions app/config/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,63 @@
convict.addFormat(arrayFormat('oidc-client'));
convict.addFormat(arrayFormat('service-account'));

/**
* Validates a comma-separated string of domains or FQDNs.
* Allows the wildcard character `*` to indicate all origins.
* Supports the value `disable` to explicitly disable CORS.
*
* A valid FQDN must:
* - Contain only alphanumeric characters, hyphens, and dots.
* - Have at least one dot separating the domain levels.
* - End with a valid top-level domain (e.g., `.com`, `.org`).
*
* @param {string} value - The input string to validate. Can be a single domain, a wildcard (`*`), `disable`, or a comma-separated list of domains.
* @throws {Error} If any domain in the list is invalid.
*
* @example
* // Valid examples:
* validateDomains('*'); // No error
* validateDomains('example.com'); // No error
* validateDomains('example.com,api.example.com,sub.example.co.uk'); // No error
* validateDomains('disable'); // No error
*
* // Invalid examples:
* validateDomains('http://example.com'); // Throws error (protocol is not allowed)
* validateDomains('example_com'); // Throws error (underscore is not allowed)
* validateDomains('example'); // Throws error (missing top-level domain)
* validateDomains(',example.com'); // Throws error (empty domain before the comma)
*/
function validateDomains(value) {
if (value === '*' || value === 'disable') {
return; // '*' allows all origins; 'disable' explicitly disables CORS.
}

// Normalize value to an array of origins
const origins = Array.isArray(value)
? value

Check failure on line 101 in app/config/config.js

View workflow job for this annotation

GitHub Actions / static-checks

'?' should be placed at the end of the line
: value.split(',').map(origin => origin.trim());

Check failure on line 102 in app/config/config.js

View workflow job for this annotation

GitHub Actions / static-checks

':' should be placed at the end of the line

// Regex to validate FQDNs with or without protocols
const originRegex = /^(https?:\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/;

origins.forEach(origin => {
if (!originRegex.test(origin)) {
throw new Error(`Invalid domain: ${origin}`);
}
});
}

convict.addFormat({
name: 'domains',
validate: validateDomains,
coerce: value => {
if (Array.isArray(value)) {
return value.map(origin => origin.trim());
}
return value.split(',').map(origin => origin.trim()); // Normalize strings to arrays
},
});

function loadConfig() {
const config = convict({
server: {
Expand All @@ -74,11 +131,11 @@
default: 3000,
env: 'PORT'
},
enableCorsAnyOrigin: {
doc: 'Access-Control-Allow-Origin will be set to the wildcard (*), allowing requests from any domain to access the REST API endpoints',
format: Boolean,
default: true,
env: 'ENABLE_CORS_ANY_ORIGIN'
corsAllowedOrigins: {
doc: 'Comma-separated list of origins allowed to access the REST API endpoints. Use * to allow any origin.',
format: 'domains',
default: '*',
env: 'CORS_ALLOWED_ORIGINS'
}
},
app: {
Expand Down
76 changes: 63 additions & 13 deletions app/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,68 @@ function disableUpgradeInsecureRequests(app, helmet) {
}));
}

/**
* Configures and applies the CORS middleware to the Express application.
*
* - If `corsAllowedOrigins` is set to `disable`, CORS middleware is not applied, effectively disabling CORS.
* - If `corsAllowedOrigins` is `*`, it allows all origins.
* - Otherwise, it parses the comma-separated list of origins and uses them as the allowed origins.
*
* @param {import('express').Application} app - The Express application instance.
* @param {Object} config - The application configuration object.
* @param {Object} config.server - The server-specific configuration.
* @param {string} config.server.corsAllowedOrigins - The CORS allowed origins setting.
* @param {import('winston').Logger} logger - The logger instance for logging messages.
*
* @throws {Error} Throws an error if the configuration is invalid or missing required fields.
*
* @example
* // CORS is disabled
* const config = { server: { corsAllowedOrigins: 'disable' } };
* setupCors(app, config, logger); // No CORS middleware applied
*
* @example
* // CORS allows all origins
* const config = { server: { corsAllowedOrigins: '*' } };
* setupCors(app, config, logger); // CORS middleware with `origin: true`
*
* @example
* // CORS with specific origins
* const config = { server: { corsAllowedOrigins: 'example.com,api.example.com' } };
* setupCors(app, config, logger); // CORS middleware with specific origins
*/
function setupCors(app, config, logger) {
const corsAllowedOrigins = config.server.corsAllowedOrigins;

if (corsAllowedOrigins === 'disable') {
logger.info('CORS is disabled');
return; // Skip setting up the CORS middleware
}

const cors = require('cors');

// Normalize corsAllowedOrigins to an array of origins
let origins;
if (typeof corsAllowedOrigins === 'string') {
origins = corsAllowedOrigins === '*' ? true : corsAllowedOrigins.split(',').map(origin => origin.trim());
} else if (Array.isArray(corsAllowedOrigins)) {
origins = corsAllowedOrigins; // Already an array
} else {
throw new Error(
`Invalid value for server.corsAllowedOrigins: expected a string or array, but got ${typeof corsAllowedOrigins}`
);
}

const corsOptions = {
credentials: true,
origin: origins,
};

app.use(cors(corsOptions));

logger.info(`CORS is enabled for domains: ${origins}`)
}

/**
* Creates a new instance of the express app.
* @return The new express app
Expand All @@ -37,19 +99,7 @@ exports.initializeApp = async function() {
const requestId = require('./lib/requestId');
app.use(requestId);

// Allow CORS
if (config.server.enableCorsAnyOrigin) {
logger.info('CORS is enabled');
const cors = require('cors');
const corsOptions = {
credentials: true,
origin: true
};
app.use(cors(corsOptions));
}
else {
logger.info('CORS is not enabled');
}
setupCors(app, config, logger);

// Compress response bodies
const compression = require('compression');
Expand Down
13 changes: 0 additions & 13 deletions app/tests/shared/keycloak.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,19 +119,6 @@ async function getClientSecret(basePath, realmName, idOfClient, token) {
}
}

async function getWellKnownConfiguration(basePath, realmName, token) {
try {
const res = await request
.get(`${ basePath }/realms/${ realmName }/.well-known/openid-configuration`)
.set('Authorization', `bearer ${ token }`);
console.log(res);
}
catch (err) {
logger.error('Unable to get well known configuration');
throw err;
}
}

async function createUser(basePath, realmName, userOptions, token) {
const userData = {
email: userOptions.email,
Expand Down
Loading