diff --git a/.changeset/polite-dots-dig.md b/.changeset/polite-dots-dig.md new file mode 100644 index 000000000..8afd3a2b9 --- /dev/null +++ b/.changeset/polite-dots-dig.md @@ -0,0 +1,5 @@ +--- +'@salesforce/mrt-utilities': minor +--- + +Update proxy to keep user agent and ACH for SCAPI proxy diff --git a/packages/mrt-utilities/src/middleware/middleware.ts b/packages/mrt-utilities/src/middleware/middleware.ts index 2ed42e43d..aaf150554 100644 --- a/packages/mrt-utilities/src/middleware/middleware.ts +++ b/packages/mrt-utilities/src/middleware/middleware.ts @@ -15,7 +15,7 @@ * @version 0.0.1 */ -import {Headers} from '../utils/ssr-proxying.js'; +import {Headers, DEFAULT_ACCESS_CONTROL_FORWARDING_HOSTNAMES} from '../utils/ssr-proxying.js'; import { configureProxying, type ProxyResult, @@ -327,12 +327,21 @@ export const createMRTProxyMiddlewares = ( appProtocol: string = 'http', includeCaching: boolean = false, createProxyFn?: CreateProxyMiddlewareFn, + accessControlHeaderForwardingHostnames: string[] = DEFAULT_ACCESS_CONTROL_FORWARDING_HOSTNAMES, + preserveUserAgent: boolean = true, ): ProxyResult[] => { if (!proxyConfigs) { return []; } const {appHostname} = getRequestProcessorParameters(); - const proxies: ProxyResult[] = configureProxying(proxyConfigs, appHostname, appProtocol, createProxyFn); + const proxies: ProxyResult[] = configureProxying( + proxyConfigs, + appHostname, + appProtocol, + createProxyFn, + accessControlHeaderForwardingHostnames, + preserveUserAgent, + ); const middlewares: ProxyResult[] = []; proxies.forEach((proxy) => { const proxyPath = `${PROXY_PATH_BASE}/${proxy.path}`; diff --git a/packages/mrt-utilities/src/utils/configure-proxying.ts b/packages/mrt-utilities/src/utils/configure-proxying.ts index aed696372..d18258cc8 100644 --- a/packages/mrt-utilities/src/utils/configure-proxying.ts +++ b/packages/mrt-utilities/src/utils/configure-proxying.ts @@ -35,6 +35,10 @@ interface ApplyProxyRequestHeadersParams { targetHost: string; /** The protocol to use for the target */ targetProtocol: string; + /** Hostname suffixes for which x-sfdc-access-control should be forwarded */ + accessControlHeaderForwardingHostnames?: string[]; + /** When true, preserve the original User-Agent header in non-caching proxy requests */ + preserveUserAgent?: boolean; /** @internal Test hook: override rewrite function */ rewriteRequestHeaders?: ( opts: Parameters[0], @@ -57,6 +61,10 @@ interface ConfigureProxyParams { appProtocol?: string; /** Whether this is a caching proxy */ caching?: boolean; + /** Hostname suffixes for which x-sfdc-access-control should be forwarded */ + accessControlHeaderForwardingHostnames?: string[]; + /** When true, preserve the original User-Agent header in non-caching proxy requests */ + preserveUserAgent?: boolean; } /** @@ -122,6 +130,8 @@ export const applyProxyRequestHeaders = ({ proxyPath, targetHost, targetProtocol, + accessControlHeaderForwardingHostnames, + preserveUserAgent, rewriteRequestHeaders: rewriteFn, }: ApplyProxyRequestHeadersParams): void => { const headers = incomingRequest.headers; @@ -133,6 +143,8 @@ export const applyProxyRequestHeaders = ({ headerFormat: 'http', targetHost, targetProtocol, + accessControlHeaderForwardingHostnames, + preserveUserAgent, }); // Copy any new and updated headers to the proxyRequest @@ -195,6 +207,8 @@ export const configureProxy = ( targetHost, appProtocol = /* istanbul ignore next */ 'https', caching, + accessControlHeaderForwardingHostnames, + preserveUserAgent, }: ConfigureProxyParams, createProxyFn?: CreateProxyMiddlewareFn, ): ProxyResult => { @@ -242,6 +256,8 @@ export const configureProxy = ( proxyPath, targetHost, targetProtocol, + accessControlHeaderForwardingHostnames, + preserveUserAgent, }); }, @@ -302,6 +318,8 @@ export const configureProxying = ( appHostname: string, appProtocol: string = 'https', createProxyFn?: CreateProxyMiddlewareFn, + accessControlHeaderForwardingHostnames?: string[], + preserveUserAgent?: boolean, ): ProxyResult[] => { const proxies: ProxyResult[] = []; proxyConfigs.forEach((config) => { @@ -315,6 +333,8 @@ export const configureProxying = ( appProtocol, appHostname, caching: false, + accessControlHeaderForwardingHostnames, + preserveUserAgent, }, createProxyFn, ); diff --git a/packages/mrt-utilities/src/utils/ssr-proxying.ts b/packages/mrt-utilities/src/utils/ssr-proxying.ts index f2680d013..8c7599593 100644 --- a/packages/mrt-utilities/src/utils/ssr-proxying.ts +++ b/packages/mrt-utilities/src/utils/ssr-proxying.ts @@ -766,7 +766,19 @@ export const rewriteProxyResponseHeaders = ({ * List of x- headers that are removed from proxied requests. * @private */ -export const X_HEADERS_TO_REMOVE_PROXY: string[] = ['x-mobify-access-key', 'x-sfdc-access-control']; +export const X_HEADERS_TO_REMOVE_PROXY: string[] = ['x-mobify-access-key']; + +const X_SFDC_ACCESS_CONTROL = 'x-sfdc-access-control'; + +export const DEFAULT_ACCESS_CONTROL_FORWARDING_HOSTNAMES: string[] = ['.commercecloud.salesforce.com']; + +export const hostnameMatchesTransformationList = (hostname: string, hostnameSuffixes?: string[] | null): boolean => { + if (!hostnameSuffixes || hostnameSuffixes.length === 0) { + return false; + } + const hostnameOnly = hostname.split(':')[0]; + return hostnameSuffixes.some((suffix) => hostnameOnly.endsWith(suffix)); +}; /** * List of x- headers that are removed from origin requests. @@ -846,6 +858,10 @@ interface RewriteProxyRequestHeadersParams { targetHost: string; /** true to log operations */ logging?: boolean; + /** hostname suffixes for which x-sfdc-access-control should be forwarded; empty/undefined = always strip */ + accessControlHeaderForwardingHostnames?: string[]; + /** when true, preserve the original User-Agent header in non-caching proxy requests */ + preserveUserAgent?: boolean; } /** @@ -869,6 +885,8 @@ export const rewriteProxyRequestHeaders = ({ targetProtocol, targetHost, logging = false, + accessControlHeaderForwardingHostnames, + preserveUserAgent = true, }: RewriteProxyRequestHeadersParams): AWSHeaders | HTTPHeaders | IncomingHttpHeaders => { if (!headers) { return {}; @@ -878,6 +896,12 @@ export const rewriteProxyRequestHeaders = ({ // Strip out some specific X-headers X_HEADERS_TO_REMOVE_PROXY.forEach((key) => workingHeaders.deleteHeader(key)); + // Conditionally strip x-sfdc-access-control. + // Forward it only for non-caching requests to hosts matching the suffix list. + if (caching || !hostnameMatchesTransformationList(targetHost, accessControlHeaderForwardingHostnames)) { + workingHeaders.deleteHeader(X_SFDC_ACCESS_CONTROL); + } + // For a caching proxy, apply special header processing if (caching) { // Remove any headers that are not on the allowlist @@ -912,9 +936,9 @@ export const rewriteProxyRequestHeaders = ({ workingHeaders.setHeader(ORIGIN, targetOrigin); } - // Replace some headers with hardwired values - if (workingHeaders.getHeader(USER_AGENT)) { - // Mimic the behaviour of CloudFront + // Replace User-Agent unless preserveUserAgent is set for non-caching proxies. + // Caching proxies always override User-Agent (handled above). + if (!preserveUserAgent && workingHeaders.getHeader(USER_AGENT)) { workingHeaders.setHeader(USER_AGENT, 'Amazon CloudFront'); } diff --git a/packages/mrt-utilities/test/utils/configure-proxying.test.ts b/packages/mrt-utilities/test/utils/configure-proxying.test.ts index 7bd65b415..73472d70c 100644 --- a/packages/mrt-utilities/test/utils/configure-proxying.test.ts +++ b/packages/mrt-utilities/test/utils/configure-proxying.test.ts @@ -96,6 +96,45 @@ describe('proxying', () => { expect(mockProxyRequest.setHeader.called).to.be.false; expect(mockProxyRequest.removeHeader.called).to.be.false; }); + + it('forwards x-sfdc-access-control when hostname matches forwarding list', () => { + mockIncomingRequest.headers = { + 'x-sfdc-access-control': 'test-value', + 'content-type': 'application/json', + }; + + applyProxyRequestHeaders({ + proxyRequest: mockProxyRequest as unknown as ClientRequest, + incomingRequest: mockIncomingRequest as unknown as IncomingMessage, + caching: false, + proxyPath: '/api', + targetHost: 'api.commercecloud.salesforce.com', + targetProtocol: 'https', + accessControlHeaderForwardingHostnames: ['.commercecloud.salesforce.com'], + }); + + expect(mockProxyRequest.setHeader.calledWith('x-sfdc-access-control', 'test-value')).to.be.true; + expect(mockProxyRequest.removeHeader.calledWith('x-sfdc-access-control')).to.be.false; + }); + + it('strips x-sfdc-access-control when hostname does not match forwarding list', () => { + mockIncomingRequest.headers = { + 'x-sfdc-access-control': 'test-value', + 'content-type': 'application/json', + }; + + applyProxyRequestHeaders({ + proxyRequest: mockProxyRequest as unknown as ClientRequest, + incomingRequest: mockIncomingRequest as unknown as IncomingMessage, + caching: false, + proxyPath: '/api', + targetHost: 'other.example.com', + targetProtocol: 'https', + accessControlHeaderForwardingHostnames: ['.commercecloud.salesforce.com'], + }); + + expect(mockProxyRequest.removeHeader.calledWith('x-sfdc-access-control')).to.be.true; + }); }); describe('configureProxy', () => { diff --git a/packages/mrt-utilities/test/utils/ssr-proxying.test.ts b/packages/mrt-utilities/test/utils/ssr-proxying.test.ts index 96c4ef1a2..433a056f1 100644 --- a/packages/mrt-utilities/test/utils/ssr-proxying.test.ts +++ b/packages/mrt-utilities/test/utils/ssr-proxying.test.ts @@ -14,6 +14,7 @@ import { rfc1123, MAX_URL_LENGTH_BYTES, ALLOWED_CACHING_PROXY_REQUEST_HEADERS, + hostnameMatchesTransformationList, type HTTPHeaders, type AWSHeaders, type ParsedHost, @@ -82,6 +83,8 @@ interface RewriteProxyRequestHeadersTestCase { targetProtocol?: string; testAllowlist?: boolean; method?: string; + accessControlHeaderForwardingHostnames?: string[]; + preserveUserAgent?: boolean; } describe('rfc1123 tests', () => { @@ -714,6 +717,115 @@ describe('rewriteProxyRequestHeaders tests', () => { 'x-mobify': 'true', }, }, + { + name: 'forward x-sfdc-access-control when hostname matches forwarding list', + targetHost: 'api.commercecloud.salesforce.com', + accessControlHeaderForwardingHostnames: ['.commercecloud.salesforce.com'], + input: { + 'accept-encoding': 'deflate, gzip', + 'x-sfdc-access-control': 'abc123', + }, + expected: { + 'accept-encoding': 'deflate, gzip', + 'x-sfdc-access-control': 'abc123', + }, + }, + { + name: 'strip x-sfdc-access-control when hostname does not match forwarding list', + targetHost: 'other.example.com', + accessControlHeaderForwardingHostnames: ['.commercecloud.salesforce.com'], + input: { + 'accept-encoding': 'deflate, gzip', + 'x-sfdc-access-control': 'abc123', + }, + expected: { + 'accept-encoding': 'deflate, gzip', + // @ts-expect-error: Testing undefined value + 'x-sfdc-access-control': undefined, + }, + }, + { + name: 'strip x-sfdc-access-control when forwarding list is empty', + targetHost: 'api.commercecloud.salesforce.com', + accessControlHeaderForwardingHostnames: [], + input: { + 'accept-encoding': 'deflate, gzip', + 'x-sfdc-access-control': 'abc123', + }, + expected: { + 'accept-encoding': 'deflate, gzip', + // @ts-expect-error: Testing undefined value + 'x-sfdc-access-control': undefined, + }, + }, + { + name: 'no-op when x-sfdc-access-control not present and hostname matches', + targetHost: 'api.commercecloud.salesforce.com', + accessControlHeaderForwardingHostnames: ['.commercecloud.salesforce.com'], + input: { + 'accept-encoding': 'deflate, gzip', + }, + expected: { + 'accept-encoding': 'deflate, gzip', + }, + }, + { + name: 'strip x-sfdc-access-control for caching proxy even when hostname matches', + targetHost: 'api.commercecloud.salesforce.com', + accessControlHeaderForwardingHostnames: ['.commercecloud.salesforce.com'], + caching: true, + input: { + 'x-sfdc-access-control': 'abc123', + }, + expected: { + // @ts-expect-error: Testing undefined value + 'x-sfdc-access-control': undefined, + }, + }, + { + name: 'preserves original User-Agent when preserveUserAgent is true', + targetHost: 'www.customer.com', + preserveUserAgent: true, + input: { + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + }, + expected: { + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + }, + }, + { + name: 'overwrites User-Agent when preserveUserAgent is false', + targetHost: 'www.customer.com', + preserveUserAgent: false, + input: { + 'user-agent': 'Mozilla/5.0 (Linux; Android 10)', + }, + expected: { + 'user-agent': 'Amazon CloudFront', + }, + }, + { + name: 'preserves User-Agent when preserveUserAgent is undefined (default is true)', + targetHost: 'www.customer.com', + input: { + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X)', + }, + expected: { + 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X)', + }, + }, + { + name: 'caching proxy always overwrites User-Agent even when preserveUserAgent is true', + targetHost: 'www.customer.com', + caching: true, + preserveUserAgent: true, + input: { + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0)', + }, + expected: { + 'user-agent': 'Amazon CloudFront', + }, + }, ]; testCases.forEach((testCase, testCaseIndex) => @@ -735,6 +847,8 @@ describe('rewriteProxyRequestHeaders tests', () => { targetProtocol: testCase.targetProtocol || 'https', targetHost: testCase.targetHost || '', logging: true, + accessControlHeaderForwardingHostnames: testCase.accessControlHeaderForwardingHostnames, + preserveUserAgent: testCase.preserveUserAgent, }); const expectedKeys = Object.keys(testCase.expected); @@ -768,3 +882,40 @@ describe('rewriteProxyRequestHeaders tests', () => { expect(() => new Headers({}, 'unknown' as 'http')).to.throw(); }); }); + +describe('hostnameMatchesTransformationList', () => { + it('returns false for empty array', () => { + expect(hostnameMatchesTransformationList('api.example.com', [])).to.be.false; + }); + + it('returns false for null', () => { + expect(hostnameMatchesTransformationList('api.example.com', null)).to.be.false; + }); + + it('returns false for undefined', () => { + expect(hostnameMatchesTransformationList('api.example.com', undefined)).to.be.false; + }); + + it('returns false when hostname does not match any suffix', () => { + expect(hostnameMatchesTransformationList('other.example.com', ['.commercecloud.salesforce.com'])).to.be.false; + }); + + it('returns true when hostname ends with a suffix', () => { + expect(hostnameMatchesTransformationList('api.commercecloud.salesforce.com', ['.commercecloud.salesforce.com'])).to + .be.true; + }); + + it('returns true with multiple suffixes', () => { + expect( + hostnameMatchesTransformationList('test.exp-delivery-staging.com', [ + '.commercecloud.salesforce.com', + '.exp-delivery-staging.com', + ]), + ).to.be.true; + }); + + it('strips port before matching', () => { + expect(hostnameMatchesTransformationList('api.commercecloud.salesforce.com:443', ['.commercecloud.salesforce.com'])) + .to.be.true; + }); +});