From 9a48c302d3f6a0a6f70f7b0023666cd8e5166597 Mon Sep 17 00:00:00 2001 From: Filipe Boleto Date: Thu, 16 Jul 2026 01:05:33 +0100 Subject: [PATCH] feature/md-2157-forward-client-ip-cdn-proxies --- .changeset/forward-client-ip.md | 5 ++ src/MOSProxy.ts | 16 ++++- src/MOSProxyBuilder.ts | 12 ++++ src/adapters/ClientIPProvider.ts | 8 +++ src/adapters/index.ts | 2 + src/index.ts | 1 + src/stages/fetchSurfaceDecisions.ts | 4 +- src/stages/mosAuthenticatedApi.ts | 25 ++++++- src/stages/surfaceDecisions.ts | 4 ++ test/MOSProxy.spec.ts | 83 +++++++++++++++++++++++ test/clientIPProvider.spec.ts | 13 ++++ test/stages/fetchSurfaceDecisions.spec.ts | 28 ++++++++ test/stages/surfaceDecisions.spec.ts | 8 ++- 13 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 .changeset/forward-client-ip.md create mode 100644 src/adapters/ClientIPProvider.ts create mode 100644 test/clientIPProvider.spec.ts diff --git a/.changeset/forward-client-ip.md b/.changeset/forward-client-ip.md new file mode 100644 index 00000000..904803d9 --- /dev/null +++ b/.changeset/forward-client-ip.md @@ -0,0 +1,5 @@ +--- +'@monetizationos/proxy': patch +--- + +Add `MOSProxyBuilder.withClientIP((request) => ...)` so each CDN runtime supplies the end-user IP for MOS API requests as `http.clientIP`. Applies to surface-decisions and authenticated `/mos-api/*` routes. diff --git a/src/MOSProxy.ts b/src/MOSProxy.ts index 2b3333cd..983bc00e 100644 --- a/src/MOSProxy.ts +++ b/src/MOSProxy.ts @@ -1,3 +1,4 @@ +import type { ClientIPProvider } from './adapters/ClientIPProvider' import type { ClientMetadataProvider } from './adapters/ClientMetadataProvider' import type { ConfigFactory, UnresolvedConfigHandler } from './adapters/ConfigFactory' import type { Fetcher } from './adapters/Fetcher' @@ -48,6 +49,7 @@ export interface MOSProxyOptions { apiFetcher: Fetcher | null htmlRewriter: HtmlRewriterAdapter | null clientMetadataProvider: ClientMetadataProvider | null + clientIpProvider: ClientIPProvider | null identityProvider: IdentityProvider | null resourceProvider: ResourceProvider | null onUnresolvedConfig?: UnresolvedConfigHandler | null @@ -83,8 +85,16 @@ export class MOSProxy { } async handle(request: Request): Promise { - const { originFetcher, apiFetcher, htmlRewriter, clientMetadataProvider, identityProvider, resourceProvider, onHtmlPipelineError } = - this.opts + const { + originFetcher, + apiFetcher, + htmlRewriter, + clientMetadataProvider, + clientIpProvider, + identityProvider, + resourceProvider, + onHtmlPipelineError, + } = this.opts const logger = this.logger const resolved = await this.configResolution.resolve(request) @@ -111,6 +121,7 @@ export class MOSProxy { ctx, identityProvider, clientMetadataProvider, + clientIpProvider, apiFetcher, ) if (authenticatedApiResult) { @@ -158,6 +169,7 @@ export class MOSProxy { apiFetcher, htmlRewriter, clientMetadataProvider, + clientIpProvider, identityProvider, resourceProvider, ) diff --git a/src/MOSProxyBuilder.ts b/src/MOSProxyBuilder.ts index c22d7f59..b707c7d9 100644 --- a/src/MOSProxyBuilder.ts +++ b/src/MOSProxyBuilder.ts @@ -1,3 +1,4 @@ +import type { ClientIPProvider } from './adapters/ClientIPProvider' import type { ClientMetadataProvider } from './adapters/ClientMetadataProvider' import type { ConfigFactory, UnresolvedConfigHandler } from './adapters/ConfigFactory' import type { Fetcher } from './adapters/Fetcher' @@ -48,6 +49,7 @@ export class MOSProxyBuilder { private _apiFetcher: Fetcher = globalThis.fetch private _htmlRewriter: HtmlRewriterAdapter | null = null private _clientMetadataProvider: ClientMetadataProvider | null = null + private _clientIpProvider: ClientIPProvider | null = null private _identityProvider: IdentityProvider | null = null private _resourceProvider: ResourceProvider | null = null private _onUnresolvedConfig: UnresolvedConfigHandler | null = null @@ -92,6 +94,15 @@ export class MOSProxyBuilder { return this } + /** + * Supplies the end-user client IP for MOS API requests as `http.clientIP`. + * Each CDN runtime knows how to read the visitor IP from its own request object. + */ + withClientIP(provider: ClientIPProvider): this { + this._clientIpProvider = provider + return this + } + /** * Override identity provision for the surface-decisions API. Provide `resolve` to control the * identity payload sent to the API (e.g. resolved from a request header instead of cookies), @@ -206,6 +217,7 @@ export class MOSProxyBuilder { apiFetcher: this._apiFetcher, htmlRewriter: this._htmlRewriter, clientMetadataProvider: this._clientMetadataProvider, + clientIpProvider: this._clientIpProvider, identityProvider: this._identityProvider, resourceProvider: this._resourceProvider, onUnresolvedConfig: this._onUnresolvedConfig ?? undefined, diff --git a/src/adapters/ClientIPProvider.ts b/src/adapters/ClientIPProvider.ts new file mode 100644 index 00000000..6870bbf9 --- /dev/null +++ b/src/adapters/ClientIPProvider.ts @@ -0,0 +1,8 @@ +/** + * Provides the end-user client IP for the MOS API `http.clientIP` field. + * Each CDN runtime supplies its own provider via {@link MOSProxyBuilder.withClientIP}. + */ +export type ClientIPProvider = (request: Request) => string | null | undefined + +export const normalizeClientIP = (value: string | null | undefined): string | undefined => + typeof value === 'string' && value.length > 0 ? value : undefined diff --git a/src/adapters/index.ts b/src/adapters/index.ts index f3417b92..c2652213 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -1,3 +1,5 @@ +export type { ClientIPProvider } from './ClientIPProvider' +export { normalizeClientIP } from './ClientIPProvider' export type { ClientMetadataProvider } from './ClientMetadataProvider' export type { ConfigFactory, diff --git a/src/index.ts b/src/index.ts index fdfe580a..3a254b14 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export type { + ClientIPProvider, ClientMetadataProvider, ConfigFactory, ContentOptions, diff --git a/src/stages/fetchSurfaceDecisions.ts b/src/stages/fetchSurfaceDecisions.ts index 7d222edd..70e3ff79 100644 --- a/src/stages/fetchSurfaceDecisions.ts +++ b/src/stages/fetchSurfaceDecisions.ts @@ -11,6 +11,7 @@ export type FetchSurfaceDecisionsArgs = { clientMetadata: Record resource: Resource userAgent?: string | undefined + clientIP?: string | undefined referer?: string | undefined cookies?: Record | undefined originStatus: number @@ -33,7 +34,7 @@ export type FetchSurfaceDecisionsResult = export default async function fetchSurfaceDecisions( ctx: PipelineContext, - { identity, url, clientMetadata, resource, userAgent, referer, cookies, originStatus }: FetchSurfaceDecisionsArgs, + { identity, url, clientMetadata, resource, userAgent, clientIP, referer, cookies, originStatus }: FetchSurfaceDecisionsArgs, apiFetcher: Fetcher, ): Promise { const { config } = ctx @@ -45,6 +46,7 @@ export default async function fetchSurfaceDecisions( http: { url, userAgent, + clientIP, referer, cookies, proxyOrigin: { diff --git a/src/stages/mosAuthenticatedApi.ts b/src/stages/mosAuthenticatedApi.ts index 3df58227..1534c9a3 100644 --- a/src/stages/mosAuthenticatedApi.ts +++ b/src/stages/mosAuthenticatedApi.ts @@ -1,4 +1,11 @@ -import { type ClientMetadataProvider, defaultResolveIdentity, type Fetcher, type IdentityProvider } from '../adapters' +import { + type ClientIPProvider, + type ClientMetadataProvider, + defaultResolveIdentity, + type Fetcher, + type IdentityProvider, +} from '../adapters' +import { normalizeClientIP } from '../adapters/ClientIPProvider' import { withMosProxyHeaders } from '../apiRequestHeaders' import type { PipelineContext } from '../context' import type { MosAuthenticatedApiRoute } from '../types' @@ -9,6 +16,7 @@ export const handleMosAuthenticatedApiRoutes = async ( ctx: PipelineContext, identityProvider: IdentityProvider | null, clientMetadataProvider: ClientMetadataProvider | null, + clientIpProvider: ClientIPProvider | null, apiFetcher: Fetcher | null, ): Promise => { if (!routes?.length) { @@ -18,7 +26,15 @@ export const handleMosAuthenticatedApiRoutes = async ( const url = new URL(request.url) for (const route of routes) { if (url.pathname === route.matchPath && request.method.toUpperCase() === route.method) { - return handleMosAuthenticatedApiRoute(request, route, ctx, identityProvider, clientMetadataProvider, apiFetcher) + return handleMosAuthenticatedApiRoute( + request, + route, + ctx, + identityProvider, + clientMetadataProvider, + clientIpProvider, + apiFetcher, + ) } } @@ -31,6 +47,7 @@ const handleMosAuthenticatedApiRoute = async ( ctx: PipelineContext, identityProvider: IdentityProvider | null, clientMetadataProvider: ClientMetadataProvider | null, + clientIpProvider: ClientIPProvider | null, apiFetcher: Fetcher | null, ): Promise => { if (!apiFetcher) { @@ -40,17 +57,19 @@ const handleMosAuthenticatedApiRoute = async ( const { config, logger } = ctx const requestBody = await request.json() const identity = await (identityProvider?.resolve ?? defaultResolveIdentity)({ request, config, logger }) + const clientMetadata = clientMetadataProvider?.build(request) ?? {} return await apiFetcher( new Request(new URL(route.mosPath, config.mosHost), { method: route.method, body: JSON.stringify({ ...requestBody, - ...(clientMetadataProvider?.build(request) ?? {}), + ...clientMetadata, identity, http: { url: request.url, userAgent: request.headers.get('User-Agent') ?? undefined, + clientIP: normalizeClientIP(clientIpProvider?.(request)), referer: request.headers.get('Referer') ?? undefined, }, }), diff --git a/src/stages/surfaceDecisions.ts b/src/stages/surfaceDecisions.ts index 122139c2..aa37bdee 100644 --- a/src/stages/surfaceDecisions.ts +++ b/src/stages/surfaceDecisions.ts @@ -1,3 +1,5 @@ +import type { ClientIPProvider } from '../adapters/ClientIPProvider' +import { normalizeClientIP } from '../adapters/ClientIPProvider' import type { ClientMetadataProvider } from '../adapters/ClientMetadataProvider' import type { Fetcher } from '../adapters/Fetcher' import type { HtmlRewriterAdapter } from '../adapters/HtmlRewriterAdapter' @@ -16,6 +18,7 @@ export default async function getSurfaceDecisions( apiFetcher: Fetcher, rewriter: HtmlRewriterAdapter | null, clientMetadataProvider: ClientMetadataProvider | null, + clientIpProvider: ClientIPProvider | null, identityProvider: IdentityProvider | null, resourceProvider: ResourceProvider | null, ): Promise<[Response, SurfaceDecisionResponse | null]> { @@ -51,6 +54,7 @@ export default async function getSurfaceDecisions( clientMetadata, resource, userAgent: request.headers.get('User-Agent') ?? undefined, + clientIP: normalizeClientIP(clientIpProvider?.(request)), referer: request.headers.get('Referer') ?? undefined, cookies: selectSurfaceDecisionCookies(request.headers.get('Cookie'), response, config.surfaceDecisionsCookiePatterns), originStatus: response.status, diff --git a/test/MOSProxy.spec.ts b/test/MOSProxy.spec.ts index 8e4400db..cde8aba5 100644 --- a/test/MOSProxy.spec.ts +++ b/test/MOSProxy.spec.ts @@ -258,6 +258,52 @@ describe('MOSProxy pipeline', () => { expect(payload.cloudflare).toBeUndefined() }) + it('forwards client IP from withClientIP as http.clientIP in surface decisions', async () => { + const originFetcher = MockFetcher(() => htmlResponse('

', { status: 200 })) + const apiFetcher = MockFetcher(() => decisionsResponse()) + + const proxy = new MOSProxyBuilder() + .withConfig(baseConfig) + .withOriginFetcher(originFetcher) + .withApiFetcher(apiFetcher) + .withHtmlRewriter(new PassthroughHtmlRewriter()) + .withClientMetadata({ build: () => ({ cloudflare: { cf: { country: 'GB' } } }) }) + .withClientIP((request) => request.headers.get('CF-Connecting-IP')) + .build() + + await proxy.handle( + new Request('https://proxy.example.com/article', { + headers: { 'CF-Connecting-IP': '203.0.113.42' }, + }), + ) + + const payload = JSON.parse(await apiFetcher.calls[0]!.request.clone().text()) + expect(payload.http.clientIP).toBe('203.0.113.42') + expect(payload.cloudflare).toEqual({ cf: { country: 'GB' } }) + }) + + it('omits http.clientIP when withClientIP is not configured', async () => { + const originFetcher = MockFetcher(() => htmlResponse('

', { status: 200 })) + const apiFetcher = MockFetcher(() => decisionsResponse()) + + const proxy = new MOSProxyBuilder() + .withConfig(baseConfig) + .withOriginFetcher(originFetcher) + .withApiFetcher(apiFetcher) + .withHtmlRewriter(new PassthroughHtmlRewriter()) + .withClientMetadata({ build: () => ({ fastly: { client: { address: '198.51.100.10' } } }) }) + .build() + + await proxy.handle( + new Request('https://proxy.example.com/article', { + headers: { 'CF-Connecting-IP': '203.0.113.42' }, + }), + ) + + const payload = JSON.parse(await apiFetcher.calls[0]!.request.clone().text()) + expect(payload.http.clientIP).toBeUndefined() + }) + it('fails open for surface decisions API failures and returns the rewritten response', async () => { const { events, logger } = createMemoryLogger() const originFetcher = MockFetcher(() => htmlResponse('x', { status: 200 })) @@ -625,6 +671,43 @@ describe('custom authenticated API routes', () => { ) }) + it('forwards client IP from withClientIP as http.clientIP for offer redemption', async () => { + const proxy = new MOSProxyBuilder() + .withConfig(baseConfig) + .withApiFetcher(apiFetcher) + .withOriginFetcher(originFetcher) + .withHtmlRewriter(new PassthroughHtmlRewriter()) + .withClientMetadata({ build: () => ({ cloudflare: { cf: { country: 'GB' } } }) }) + .withClientIP((request) => request.headers.get('CF-Connecting-IP')) + .build() + + await proxy.handle( + new Request('https://proxy.example.com/mos-api/offer-redemptions', { + method: 'POST', + body: JSON.stringify({ offerToken: 'offer.abc' }), + headers: { + cookie: 'anon-session=the-session;', + 'CF-Connecting-IP': '203.0.113.42', + }, + }), + ) + + expect(requestCaptor).toHaveBeenCalledExactlyOnceWith( + 'https://api.monetizationos.com/api/v1/offer-redemptions', + 'POST', + { + cloudflare: { cf: { country: 'GB' } }, + http: { + url: 'https://proxy.example.com/mos-api/offer-redemptions', + clientIP: '203.0.113.42', + }, + identity: { anonymousIdentifier: 'the-session' }, + offerToken: 'offer.abc', + }, + expect.any(Array), + ) + }) + it('requires method to match', async () => { const proxy = new MOSProxyBuilder() .withConfig(baseConfig) diff --git a/test/clientIPProvider.spec.ts b/test/clientIPProvider.spec.ts new file mode 100644 index 00000000..be4d1d20 --- /dev/null +++ b/test/clientIPProvider.spec.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { normalizeClientIP } from '../src/adapters/ClientIPProvider' + +describe('normalizeClientIP', () => { + it.each([ + { input: '203.0.113.42', output: '203.0.113.42' }, + { input: null, output: undefined }, + { input: undefined, output: undefined }, + { input: '', output: undefined }, + ])('normalizes $input to $output', ({ input, output }) => { + expect(normalizeClientIP(input)).toBe(output) + }) +}) diff --git a/test/stages/fetchSurfaceDecisions.spec.ts b/test/stages/fetchSurfaceDecisions.spec.ts index ce430b13..0c048709 100644 --- a/test/stages/fetchSurfaceDecisions.spec.ts +++ b/test/stages/fetchSurfaceDecisions.spec.ts @@ -94,6 +94,34 @@ describe('fetchSurfaceDecisions', () => { }) }) + it('includes clientIP in http.clientIP when provided', async () => { + const fetcher = MockFetcher(() => new Response(JSON.stringify(successPayload()), { status: 200 })) + + await fetchSurfaceDecisions( + ctx, + args({ + clientIP: '203.0.113.42', + }), + fetcher, + ) + + const body = JSON.parse(await fetcher.calls[0]!.request.clone().text()) + expect(body.http).toEqual({ + url: 'https://proxy.example.com/article', + clientIP: '203.0.113.42', + proxyOrigin: { status: 200 }, + }) + }) + + it('omits http.clientIP when not provided', async () => { + const fetcher = MockFetcher(() => new Response(JSON.stringify(successPayload()), { status: 200 })) + + await fetchSurfaceDecisions(ctx, args(), fetcher) + + const body = JSON.parse(await fetcher.calls[0]!.request.clone().text()) + expect(body.http.clientIP).toBeUndefined() + }) + it('includes cookies in http.cookies when provided', async () => { const fetcher = MockFetcher(() => new Response(JSON.stringify(successPayload()), { status: 200 })) diff --git a/test/stages/surfaceDecisions.spec.ts b/test/stages/surfaceDecisions.spec.ts index c7a51898..693cfbcd 100644 --- a/test/stages/surfaceDecisions.spec.ts +++ b/test/stages/surfaceDecisions.spec.ts @@ -40,7 +40,7 @@ const anonymousCookie = (identifier: string) => `anon-session=${identifier}; Pat const run = async (pipelineCtx: PipelineContext, request: Request, apiResponse: SurfaceDecisionResponse) => { const apiFetcher = MockFetcher(() => new Response(JSON.stringify(apiResponse), { status: 200 })) - const [response] = await getSurfaceDecisions(pipelineCtx, request, htmlResponse(), apiFetcher, null, null, null, null) + const [response] = await getSurfaceDecisions(pipelineCtx, request, htmlResponse(), apiFetcher, null, null, null, null, null) return response.headers.getSetCookie() } @@ -172,6 +172,7 @@ describe('getSurfaceDecisions resource provider', () => { null, null, null, + null, resourceProvider, ) const body = JSON.parse(await apiFetcher.calls[0]!.request.clone().text()) @@ -222,6 +223,7 @@ describe('getSurfaceDecisions request cookies', () => { null, null, null, + null, ) const body = JSON.parse(await apiFetcher.calls[0]!.request.clone().text()) @@ -259,6 +261,7 @@ describe('getSurfaceDecisions request cookies', () => { null, null, null, + null, ) const body = JSON.parse(await apiFetcher.calls[0]!.request.clone().text()) @@ -298,6 +301,7 @@ describe('getSurfaceDecisions request cookies', () => { null, null, null, + null, ) const body = JSON.parse(await apiFetcher.calls[0]!.request.clone().text()) @@ -321,6 +325,7 @@ describe('getSurfaceDecisions request cookies', () => { null, null, null, + null, ) const body = JSON.parse(await apiFetcher.calls[0]!.request.clone().text()) @@ -351,6 +356,7 @@ describe('getSurfaceDecisions request cookies', () => { null, null, null, + null, ) const body = JSON.parse(await apiFetcher.calls[0]!.request.clone().text())