Skip to content

Commit

Permalink
Merge pull request #357 from center-for-threat-informed-defense/356-a…
Browse files Browse the repository at this point in the history
…dd-support-for-cors-allow-list

feat: implement support for CORS allow-list
  • Loading branch information
seansica authored Dec 30, 2024
2 parents 86203ef + e154a89 commit 370b875
Show file tree
Hide file tree
Showing 4 changed files with 127 additions and 33 deletions.
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 @@ 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.
}

// 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 @@ 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
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

0 comments on commit 370b875

Please sign in to comment.