Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/forward-client-ip.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 14 additions & 2 deletions src/MOSProxy.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -83,8 +85,16 @@ export class MOSProxy {
}

async handle(request: Request): Promise<Response> {
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)
Expand All @@ -111,6 +121,7 @@ export class MOSProxy {
ctx,
identityProvider,
clientMetadataProvider,
clientIpProvider,
apiFetcher,
)
if (authenticatedApiResult) {
Expand Down Expand Up @@ -158,6 +169,7 @@ export class MOSProxy {
apiFetcher,
htmlRewriter,
clientMetadataProvider,
clientIpProvider,
identityProvider,
resourceProvider,
)
Expand Down
12 changes: 12 additions & 0 deletions src/MOSProxyBuilder.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/adapters/ClientIPProvider.ts
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions src/adapters/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export type { ClientIPProvider } from './ClientIPProvider'
export { normalizeClientIP } from './ClientIPProvider'
export type { ClientMetadataProvider } from './ClientMetadataProvider'
export type {
ConfigFactory,
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export type {
ClientIPProvider,
ClientMetadataProvider,
ConfigFactory,
ContentOptions,
Expand Down
4 changes: 3 additions & 1 deletion src/stages/fetchSurfaceDecisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type FetchSurfaceDecisionsArgs = {
clientMetadata: Record<string, unknown>
resource: Resource
userAgent?: string | undefined
clientIP?: string | undefined
referer?: string | undefined
cookies?: Record<string, string> | undefined
originStatus: number
Expand All @@ -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<FetchSurfaceDecisionsResult> {
const { config } = ctx
Expand All @@ -45,6 +46,7 @@ export default async function fetchSurfaceDecisions(
http: {
url,
userAgent,
clientIP,
referer,
cookies,
proxyOrigin: {
Expand Down
25 changes: 22 additions & 3 deletions src/stages/mosAuthenticatedApi.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -9,6 +16,7 @@ export const handleMosAuthenticatedApiRoutes = async (
ctx: PipelineContext,
identityProvider: IdentityProvider | null,
clientMetadataProvider: ClientMetadataProvider | null,
clientIpProvider: ClientIPProvider | null,
apiFetcher: Fetcher | null,
): Promise<Response | null> => {
if (!routes?.length) {
Expand All @@ -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,
)
}
}

Expand All @@ -31,6 +47,7 @@ const handleMosAuthenticatedApiRoute = async (
ctx: PipelineContext,
identityProvider: IdentityProvider | null,
clientMetadataProvider: ClientMetadataProvider | null,
clientIpProvider: ClientIPProvider | null,
apiFetcher: Fetcher | null,
): Promise<Response | null> => {
if (!apiFetcher) {
Expand All @@ -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,
},
}),
Expand Down
4 changes: 4 additions & 0 deletions src/stages/surfaceDecisions.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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]> {
Expand Down Expand Up @@ -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,
Expand Down
83 changes: 83 additions & 0 deletions test/MOSProxy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<p/>', { 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('<p/>', { 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('<a href="https://origin.example.com/other">x</a>', { status: 200 }))
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions test/clientIPProvider.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
28 changes: 28 additions & 0 deletions test/stages/fetchSurfaceDecisions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))

Expand Down
Loading