Skip to content

Commit

Permalink
feat: implement support for CORS allow-list
Browse files Browse the repository at this point in the history
- replace ENABLE_CORS_ANY_ORIGIN with CORS_ALLOWED_ORIGINS
- add custom `domains` convict format to handle validating CORS_ALLOWED_ORIGINS
- refactor CORS middleware implementation
- See #356 for details
  • Loading branch information
seansica committed Dec 30, 2024
1 parent 86203ef commit 014dc82
Show file tree
Hide file tree
Showing 2 changed files with 105 additions and 18 deletions.
59 changes: 54 additions & 5 deletions app/config/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,55 @@ function arrayFormat(name) {
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.
}

const origins = value.split(',').map(origin => origin.trim());

// Regex to validate FQDNs
const fqdnRegex = /^(?!:\/\/)([a-zA-Z0-9-_]+\.)+[a-zA-Z]{2,}$/;

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

convict.addFormat({
name: 'domains',
validate: validateDomains,
coerce: value => value.split(',').map(origin => origin.trim()), // Normalize the input
});

function loadConfig() {
const config = convict({
server: {
Expand All @@ -74,11 +123,11 @@ function loadConfig() {
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
64 changes: 51 additions & 13 deletions app/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,56 @@ 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
}

logger.info('CORS is enabled');
const cors = require('cors');
const corsOptions = {
credentials: true,
origin: corsAllowedOrigins === '*'
? true

Check failure on line 62 in app/index.js

View workflow job for this annotation

GitHub Actions / static-checks

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

Check failure on line 63 in app/index.js

View workflow job for this annotation

GitHub Actions / static-checks

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

app.use(cors(corsOptions));
}

/**
* Creates a new instance of the express app.
* @return The new express app
Expand All @@ -37,19 +87,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

0 comments on commit 014dc82

Please sign in to comment.