From ecaca74f5e5682528b8111b93750c92dc0386748 Mon Sep 17 00:00:00 2001 From: Ricardo Devis Agullo Date: Sun, 9 Aug 2026 20:22:10 +0200 Subject: [PATCH 1/2] feat(registry): configure CORS headers --- packages/oc/src/index.ts | 7 +- .../src/registry/domain/options-sanitiser.ts | 13 +- .../validators/registry-configuration.ts | 13 +- packages/oc/src/registry/middleware/cors.ts | 93 ++++++++++++- packages/oc/src/resources/index.ts | 10 ++ packages/oc/src/types.ts | 20 +++ packages/oc/test/types/registry-cors.ts | 13 ++ .../unit/registry-domain-options-sanitiser.js | 28 ++++ .../oc/test/unit/registry-domain-validator.js | 63 +++++++++ .../oc/test/unit/registry-middleware-cors.js | 124 ++++++++++++++++++ 10 files changed, 372 insertions(+), 12 deletions(-) create mode 100644 packages/oc/test/types/registry-cors.ts create mode 100644 packages/oc/test/unit/registry-middleware-cors.js diff --git a/packages/oc/src/index.ts b/packages/oc/src/index.ts index 740568064..e6ba29790 100644 --- a/packages/oc/src/index.ts +++ b/packages/oc/src/index.ts @@ -15,4 +15,9 @@ export type { OcResponse, UploadedFile } from './registry/domain/http-server/types'; -export type { Plugin, PluginContext } from './types'; +export type { + CorsConfig, + CorsOptions, + Plugin, + PluginContext +} from './types'; diff --git a/packages/oc/src/registry/domain/options-sanitiser.ts b/packages/oc/src/registry/domain/options-sanitiser.ts index 83ceaaee3..cc14e0afe 100644 --- a/packages/oc/src/registry/domain/options-sanitiser.ts +++ b/packages/oc/src/registry/domain/options-sanitiser.ts @@ -1,8 +1,9 @@ import zlib from 'node:zlib'; import { compileSync } from 'oc-client-browser'; import settings from '../../resources/settings'; -import type { Config } from '../../types'; +import type { Config, CorsOptions } from '../../types'; import deprecate from '../../utils/deprecate'; +import { normaliseCorsConfig } from '../middleware/cors'; import * as auth from './authentication'; import createExpressAdapter from './http-server/express-adapter'; import type { HttpServerAdapterFactory } from './http-server/types'; @@ -20,7 +21,7 @@ export interface RegistryOptions< > extends Partial< Omit< Config, - 'beforePublish' | 'dataProvider' | 'discovery' | 'plugins' + 'beforePublish' | 'cors' | 'dataProvider' | 'discovery' | 'plugins' > > { /** @@ -50,6 +51,12 @@ export interface RegistryOptions< robots?: boolean; } | boolean; + /** + * CORS response headers sent by the registry. + * + * @default Existing registry CORS headers + */ + cors?: CorsOptions; /** * Public base URL where the registry will be accessible by consumers. * It **must** already include the chosen {@link Config.prefix} and end with a trailing slash. @@ -109,6 +116,8 @@ export default function optionsSanitiser< enabled: options.dataProvider?.enabled !== false }; + options.cors = normaliseCorsConfig(options.cors); + if (typeof options.discovery === 'boolean') { deprecate({ id: 'registry-config-discovery-boolean', diff --git a/packages/oc/src/registry/domain/validators/registry-configuration.ts b/packages/oc/src/registry/domain/validators/registry-configuration.ts index 5b1090a5e..0e185e8c7 100644 --- a/packages/oc/src/registry/domain/validators/registry-configuration.ts +++ b/packages/oc/src/registry/domain/validators/registry-configuration.ts @@ -1,12 +1,16 @@ import strings from '../../../resources'; -import type { Config } from '../../../types'; +import type { Config, CorsOptions } from '../../../types'; +import { validateCorsConfig } from '../../middleware/cors'; import * as auth from '../authentication'; import getMetadataAdapterOptions from '../metadata-adapter-options'; type ValidationResult = { isValid: true } | { isValid: false; message: string }; +type RegistryConfiguration = Partial< + Omit +> & { cors?: CorsOptions }; export default function registryConfiguration( - conf: Partial> + conf: RegistryConfiguration ): ValidationResult { const returnError = (message: string): ValidationResult => { return { @@ -35,6 +39,11 @@ export default function registryConfiguration( } } + const corsError = validateCorsConfig(conf.cors); + if (corsError) { + return returnError(corsError); + } + const publishAuth = conf.publishAuth; if (publishAuth) { diff --git a/packages/oc/src/registry/middleware/cors.ts b/packages/oc/src/registry/middleware/cors.ts index 397705e61..0fe2eb145 100644 --- a/packages/oc/src/registry/middleware/cors.ts +++ b/packages/oc/src/registry/middleware/cors.ts @@ -1,14 +1,93 @@ +import strings from '../../resources'; +import type { CorsConfig, CorsOptions } from '../../types'; import type { OcHandler } from '../domain/http-server/types'; +export const DEFAULT_CORS_CONFIG: CorsConfig = { + origin: '*', + credentials: true, + allowedHeaders: 'Origin, X-Requested-With, Content-Type, Accept, traceparent', + methods: 'GET, OPTIONS, PUT, POST' +}; + +const asHeaderValue = ( + value: string | string[] | undefined, + fallback: string +): string => (Array.isArray(value) ? value.join(', ') : (value ?? fallback)); + +export const normaliseCorsConfig = ( + options?: CorsOptions | null +): CorsConfig => ({ + origin: options?.origin ?? DEFAULT_CORS_CONFIG.origin, + credentials: options?.credentials ?? DEFAULT_CORS_CONFIG.credentials, + allowedHeaders: asHeaderValue( + options?.allowedHeaders, + DEFAULT_CORS_CONFIG.allowedHeaders + ), + methods: asHeaderValue(options?.methods, DEFAULT_CORS_CONFIG.methods) +}); + +const isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every((item) => typeof item === 'string'); + +export const validateCorsConfig = (options: unknown): string | undefined => { + if (typeof options === 'undefined') { + return undefined; + } + + if ( + options === null || + typeof options !== 'object' || + Array.isArray(options) + ) { + return strings.errors.registry.CONFIGURATION_CORS_MUST_BE_OBJECT; + } + + const config = options as CorsOptions; + + if ( + typeof config.origin !== 'undefined' && + (typeof config.origin !== 'string' || config.origin.length === 0) + ) { + return strings.errors.registry.CONFIGURATION_CORS_ORIGIN_MUST_BE_STRING; + } + + if ( + typeof config.credentials !== 'undefined' && + typeof config.credentials !== 'boolean' + ) { + return strings.errors.registry + .CONFIGURATION_CORS_CREDENTIALS_MUST_BE_BOOLEAN; + } + + if ( + typeof config.allowedHeaders !== 'undefined' && + typeof config.allowedHeaders !== 'string' && + !isStringArray(config.allowedHeaders) + ) { + return strings.errors.registry + .CONFIGURATION_CORS_ALLOWED_HEADERS_MUST_BE_STRING_ARRAY; + } + + if ( + typeof config.methods !== 'undefined' && + typeof config.methods !== 'string' && + !isStringArray(config.methods) + ) { + return strings.errors.registry + .CONFIGURATION_CORS_METHODS_MUST_BE_STRING_ARRAY; + } + + return undefined; +}; + const cors: OcHandler = (_req, res) => { + const options = normaliseCorsConfig(res.conf?.cors); + res.removeHeader('X-Powered-By'); - res.set('Access-Control-Allow-Credentials', 'true'); - res.set('Access-Control-Allow-Origin', '*'); - res.set( - 'Access-Control-Allow-Headers', - 'Origin, X-Requested-With, Content-Type, Accept, traceparent' - ); - res.set('Access-Control-Allow-Methods', 'GET, OPTIONS, PUT, POST'); + res.set('Access-Control-Allow-Credentials', String(options.credentials)); + res.set('Access-Control-Allow-Origin', options.origin); + res.set('Access-Control-Allow-Headers', options.allowedHeaders); + res.set('Access-Control-Allow-Methods', options.methods); }; export default cors; diff --git a/packages/oc/src/resources/index.ts b/packages/oc/src/resources/index.ts index 818007a6d..bbeaeab96 100644 --- a/packages/oc/src/resources/index.ts +++ b/packages/oc/src/resources/index.ts @@ -102,6 +102,16 @@ export default { 'context.setCookie parameters are not valid', CONFIGURATION_DEPENDENCIES_MUST_BE_ARRAY: 'Registry configuration is not valid: dependencies must be an array', + CONFIGURATION_CORS_MUST_BE_OBJECT: + 'Registry configuration is not valid: cors must be an object', + CONFIGURATION_CORS_ORIGIN_MUST_BE_STRING: + 'Registry configuration is not valid: cors.origin must be a non-empty string', + CONFIGURATION_CORS_CREDENTIALS_MUST_BE_BOOLEAN: + 'Registry configuration is not valid: cors.credentials must be a boolean', + CONFIGURATION_CORS_ALLOWED_HEADERS_MUST_BE_STRING_ARRAY: + 'Registry configuration is not valid: cors.allowedHeaders must be a string or an array of strings', + CONFIGURATION_CORS_METHODS_MUST_BE_STRING_ARRAY: + 'Registry configuration is not valid: cors.methods must be a string or an array of strings', CONFIGURATION_EMPTY: 'Registry configuration is empty', CONFIGURATION_METADATA_NOT_VALID: (adapterType: string): string => `Registry configuration is not valid: ${adapterType} is not a valid metadata adapter`, diff --git a/packages/oc/src/types.ts b/packages/oc/src/types.ts index 9f48f6f04..9cb24cb84 100644 --- a/packages/oc/src/types.ts +++ b/packages/oc/src/types.ts @@ -11,6 +11,20 @@ export type { ComponentRow, MetadataStore } from 'oc-metadata-adapters-utils'; type Middleware = (req: Request, res: Response, next: NextFunction) => void; +export interface CorsConfig { + origin: string; + credentials: boolean; + allowedHeaders: string; + methods: string; +} + +export interface CorsOptions { + origin?: string; + credentials?: boolean; + allowedHeaders?: string | string[]; + methods?: string | string[]; +} + export interface Author { email?: string; name?: string; @@ -213,6 +227,12 @@ export interface Config< * @example "https://components.mycompany.com/" */ baseUrl: string; + /** + * CORS response headers sent by the registry. + * + * @default Existing registry CORS headers + */ + cors?: CorsConfig; /** * Pre-compiled version of the `oc-client` library generated automatically * at runtime when `compileClient` is enabled (default). diff --git a/packages/oc/test/types/registry-cors.ts b/packages/oc/test/types/registry-cors.ts new file mode 100644 index 000000000..0927e6bb5 --- /dev/null +++ b/packages/oc/test/types/registry-cors.ts @@ -0,0 +1,13 @@ +import type { RegistryOptions } from '../../src/registry'; + +const options: RegistryOptions = { + baseUrl: 'https://components.example.com/', + cors: { + origin: 'https://app.example.com', + credentials: false, + allowedHeaders: ['Content-Type', 'X-Request-Id'], + methods: ['GET', 'OPTIONS'] + } +}; + +options.cors?.origin; diff --git a/packages/oc/test/unit/registry-domain-options-sanitiser.js b/packages/oc/test/unit/registry-domain-options-sanitiser.js index e62a42c66..25de80206 100644 --- a/packages/oc/test/unit/registry-domain-options-sanitiser.js +++ b/packages/oc/test/unit/registry-domain-options-sanitiser.js @@ -12,6 +12,13 @@ describe('registry : domain : options-sanitiser', () => { hotReloading: false, verbosity: 0, customHeadersToSkipOnWeakVersion: [], + cors: { + origin: '*', + credentials: true, + allowedHeaders: + 'Origin, X-Requested-With, Content-Type, Accept, traceparent', + methods: 'GET, OPTIONS, PUT, POST' + }, timeout: 120000, dataProvider: { enabled: true } }; @@ -111,6 +118,27 @@ describe('registry : domain : options-sanitiser', () => { }); }); + describe('cors configuration', () => { + it('should normalize partial custom values', () => { + const options = sanitise({ + baseUrl: 'http://my-registry.com', + cors: { + origin: 'https://app.example.com', + credentials: false, + allowedHeaders: ['Content-Type', 'X-Request-Id'], + methods: ['GET', 'OPTIONS'] + } + }); + + expect(options.cors).to.eql({ + origin: 'https://app.example.com', + credentials: false, + allowedHeaders: 'Content-Type, X-Request-Id', + methods: 'GET, OPTIONS' + }); + }); + }); + describe('fallbackRegistryUrl', () => { describe("when fallbackRegistryUrl doesn't contain / at the end of url", () => { const options = { diff --git a/packages/oc/test/unit/registry-domain-validator.js b/packages/oc/test/unit/registry-domain-validator.js index deca819ca..2cd50fdae 100644 --- a/packages/oc/test/unit/registry-domain-validator.js +++ b/packages/oc/test/unit/registry-domain-validator.js @@ -196,6 +196,69 @@ describe('registry : domain : validator', () => { }); }); + describe('cors', () => { + it('should accept partial CORS configuration', () => { + expect( + validate({ cors: { origin: 'https://app.example.com' }, s3: baseS3Conf }) + .isValid + ).to.be.true; + }); + + it('should reject a non-object CORS configuration', () => { + const result = validate({ cors: 'all', s3: baseS3Conf }); + + expect(result.isValid).to.be.false; + expect(result.message).to.equal( + 'Registry configuration is not valid: cors must be an object' + ); + }); + + it('should reject an invalid credentials value', () => { + const result = validate({ + cors: { credentials: 'true' }, + s3: baseS3Conf + }); + + expect(result.isValid).to.be.false; + expect(result.message).to.equal( + 'Registry configuration is not valid: cors.credentials must be a boolean' + ); + }); + + it('should reject an empty origin', () => { + const result = validate({ cors: { origin: '' }, s3: baseS3Conf }); + + expect(result.isValid).to.be.false; + expect(result.message).to.equal( + 'Registry configuration is not valid: cors.origin must be a non-empty string' + ); + }); + + it('should reject non-string allowed headers', () => { + const result = validate({ + cors: { allowedHeaders: ['Content-Type', 42] }, + s3: baseS3Conf + }); + + expect(result.isValid).to.be.false; + expect(result.message).to.equal( + 'Registry configuration is not valid: cors.allowedHeaders must be a string or an array of strings' + ); + }); + + it('should reject non-string methods', () => { + const result = validate({ + cors: { methods: ['GET', false] }, + s3: baseS3Conf + }); + + expect(result.isValid).to.be.false; + expect(result.message).to.equal( + 'Registry configuration is not valid: cors.methods must be a string or an array of strings' + ); + }); + }); + describe('s3', () => { describe('when local=true', () => { const conf = { local: true }; diff --git a/packages/oc/test/unit/registry-middleware-cors.js b/packages/oc/test/unit/registry-middleware-cors.js new file mode 100644 index 000000000..0563c512b --- /dev/null +++ b/packages/oc/test/unit/registry-middleware-cors.js @@ -0,0 +1,124 @@ +const expect = require('chai').expect; +const http = require('node:http'); + +const createExpressAdapter = + require('../../dist/registry/domain/http-server/express-adapter').default; +const sanitise = + require('../../dist/registry/domain/options-sanitiser').default; +const middleware = require('../../dist/registry/middleware'); + +const DEFAULT_ALLOWED_HEADERS = + 'Origin, X-Requested-With, Content-Type, Accept, traceparent'; +const DEFAULT_METHODS = 'GET, OPTIONS, PUT, POST'; + +const request = (port, method = 'GET') => + new Promise((resolve, reject) => { + const req = http.request( + { + hostname: '127.0.0.1', + method, + path: '/test', + port + }, + (res) => { + const chunks = []; + + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => + resolve({ + body: Buffer.concat(chunks).toString(), + headers: res.headers, + statusCode: res.statusCode + }) + ); + } + ); + + req.on('error', reject); + req.end(); + }); + +const startRegistry = (cors) => + new Promise((resolve, reject) => { + const adapter = createExpressAdapter(); + const options = sanitise({ + baseUrl: 'http://registry.example.com/', + compileClient: false, + local: true, + cors + }); + + middleware.bind(adapter, options); + adapter.route('get', '/test', 'test', [(_req, res) => res.send('ok')]); + + adapter.listen({ keepAliveTimeout: 1000, port: 0, timeout: 1000 }, (err) => { + if (err) { + reject(err); + return; + } + + const address = adapter.httpServer().address(); + resolve({ adapter, port: address.port }); + }); + }); + +const closeRegistry = (adapter) => + new Promise((resolve, reject) => { + adapter.close((err) => (err ? reject(err) : resolve())); + }); + +describe('registry : middleware : cors', () => { + it('should preserve the default security headers across the HTTP adapter', async () => { + const { adapter, port } = await startRegistry(); + + try { + const response = await request(port); + + expect(response.statusCode).to.equal(200); + expect(response.body).to.equal('ok'); + expect(response.headers['access-control-allow-credentials']).to.equal( + 'true' + ); + expect(response.headers['access-control-allow-origin']).to.equal('*'); + expect(response.headers['access-control-allow-headers']).to.equal( + DEFAULT_ALLOWED_HEADERS + ); + expect(response.headers['access-control-allow-methods']).to.equal( + DEFAULT_METHODS + ); + expect(response.headers['x-powered-by']).to.be.undefined; + } finally { + await closeRegistry(adapter); + } + }); + + it('should apply custom security headers across the HTTP adapter', async () => { + const { adapter, port } = await startRegistry({ + origin: 'https://app.example.com', + credentials: false, + allowedHeaders: ['Content-Type', 'X-Request-Id'], + methods: ['GET', 'OPTIONS'] + }); + + try { + const response = await request(port, 'OPTIONS'); + + expect(response.statusCode).to.equal(200); + expect(response.headers['access-control-allow-credentials']).to.equal( + 'false' + ); + expect(response.headers['access-control-allow-origin']).to.equal( + 'https://app.example.com' + ); + expect(response.headers['access-control-allow-headers']).to.equal( + 'Content-Type, X-Request-Id' + ); + expect(response.headers['access-control-allow-methods']).to.equal( + 'GET, OPTIONS' + ); + expect(response.headers['x-powered-by']).to.be.undefined; + } finally { + await closeRegistry(adapter); + } + }); +}); From 43295cbc0e7caae2377bf41769a6396d3a3bfe95 Mon Sep 17 00:00:00 2001 From: Ricardo Devis Agullo Date: Sun, 9 Aug 2026 20:32:24 +0200 Subject: [PATCH 2/2] fix(registry): omit disabled CORS credentials header --- packages/oc/src/registry/middleware/cors.ts | 5 ++++- packages/oc/test/unit/registry-middleware-cors.js | 4 +--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/oc/src/registry/middleware/cors.ts b/packages/oc/src/registry/middleware/cors.ts index 0fe2eb145..ee3c224c9 100644 --- a/packages/oc/src/registry/middleware/cors.ts +++ b/packages/oc/src/registry/middleware/cors.ts @@ -84,7 +84,10 @@ const cors: OcHandler = (_req, res) => { const options = normaliseCorsConfig(res.conf?.cors); res.removeHeader('X-Powered-By'); - res.set('Access-Control-Allow-Credentials', String(options.credentials)); + res.removeHeader('Access-Control-Allow-Credentials'); + if (options.credentials) { + res.set('Access-Control-Allow-Credentials', 'true'); + } res.set('Access-Control-Allow-Origin', options.origin); res.set('Access-Control-Allow-Headers', options.allowedHeaders); res.set('Access-Control-Allow-Methods', options.methods); diff --git a/packages/oc/test/unit/registry-middleware-cors.js b/packages/oc/test/unit/registry-middleware-cors.js index 0563c512b..9900d1305 100644 --- a/packages/oc/test/unit/registry-middleware-cors.js +++ b/packages/oc/test/unit/registry-middleware-cors.js @@ -104,9 +104,7 @@ describe('registry : middleware : cors', () => { const response = await request(port, 'OPTIONS'); expect(response.statusCode).to.equal(200); - expect(response.headers['access-control-allow-credentials']).to.equal( - 'false' - ); + expect(response.headers['access-control-allow-credentials']).to.be.undefined; expect(response.headers['access-control-allow-origin']).to.equal( 'https://app.example.com' );