From 4fe729d2e9abdd56f812afa4dddd00e7d85272d4 Mon Sep 17 00:00:00 2001 From: sc-naveenhedallaarachchi Date: Thu, 25 Jun 2026 11:51:08 +0530 Subject: [PATCH 1/2] fix(nextjs): short-circuit proxy chain on redirect responses --- .changeset/chatty-days-lie.md | 5 ++ packages/nextjs/src/proxy/proxy.test.ts | 92 +++++++++++++++++++++++++ packages/nextjs/src/proxy/proxy.ts | 27 +++++++- 3 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 .changeset/chatty-days-lie.md diff --git a/.changeset/chatty-days-lie.md b/.changeset/chatty-days-lie.md new file mode 100644 index 0000000000..aec4f12d03 --- /dev/null +++ b/.changeset/chatty-days-lie.md @@ -0,0 +1,5 @@ +--- +'@sitecore-content-sdk/nextjs': patch +--- + +[nextjs] Short-circuit the proxy chain when a handler returns 403 or a redirect (`redirected` or HTTP 3xx), so upstream redirects (e.g. next-intl locale negotiation) are preserved when composed with `defineProxy` diff --git a/packages/nextjs/src/proxy/proxy.test.ts b/packages/nextjs/src/proxy/proxy.test.ts index 9edf375ce5..7c23e1346d 100644 --- a/packages/nextjs/src/proxy/proxy.test.ts +++ b/packages/nextjs/src/proxy/proxy.test.ts @@ -756,6 +756,98 @@ describe('defineProxy', () => { expect(result).to.equal(forbidden); }); + it('should short-circuit the chain once a proxy sets a location header', async () => { + const redirect = { + status: 307, + headers: new Headers({ location: '/en' }), + } as unknown as NextResponse; + + const nextIntlProxy: ProxyHandler = { + handle: sinon.stub().resolves(redirect), + }; + const localeProxy: ProxyHandler = { + handle: sinon.stub().resolves({ status: 200 } as unknown as NextResponse), + }; + const downstreamProxy: ProxyHandler = { + handle: sinon.stub().resolves({ status: 200 } as unknown as NextResponse), + }; + + const req = {} as NextRequest; + const res = { status: 200, headers: new Headers() } as unknown as NextResponse; + + const result = await defineProxy(nextIntlProxy, localeProxy, downstreamProxy).exec(req, res); + + expect(nextIntlProxy.handle).to.have.been.calledOnce; + expect(localeProxy.handle).to.not.have.been.called; + expect(downstreamProxy.handle).to.not.have.been.called; + expect(result).to.equal(redirect); + expect(result.headers.get('location')).to.equal('/en'); + }); + + it('should short-circuit the chain once a proxy returns a 3xx response without redirected flag', async () => { + const redirect = { + status: 302, + redirected: false, + headers: new Headers({ location: '/target' }), + } as unknown as NextResponse; + + const redirectsProxy: ProxyHandler = { + handle: sinon.stub().resolves(redirect), + }; + const downstreamProxy: ProxyHandler = { + handle: sinon.stub().resolves({ status: 200 } as unknown as NextResponse), + }; + + const req = {} as NextRequest; + const res = { status: 200 } as unknown as NextResponse; + + const result = await defineProxy(redirectsProxy, downstreamProxy).exec(req, res); + + expect(redirectsProxy.handle).to.have.been.calledOnce; + expect(downstreamProxy.handle).to.not.have.been.called; + expect(result).to.equal(redirect); + }); + + it('should short-circuit the chain once a proxy sets redirected on the response', async () => { + const redirect = { + status: 301, + redirected: true, + } as unknown as NextResponse; + + const redirectsProxy: ProxyHandler = { + handle: sinon.stub().resolves(redirect), + }; + const downstreamProxy: ProxyHandler = { + handle: sinon.stub().resolves({ status: 200 } as unknown as NextResponse), + }; + + const req = {} as NextRequest; + const res = { status: 200 } as unknown as NextResponse; + + const result = await defineProxy(redirectsProxy, downstreamProxy).exec(req, res); + + expect(downstreamProxy.handle).to.not.have.been.called; + expect(result).to.equal(redirect); + }); + + it('should preserve redirect responses passed as the initial response', async () => { + const redirect = { + status: 307, + headers: new Headers({ location: '/en' }), + } as unknown as NextResponse; + + const localeProxy: ProxyHandler = { + handle: sinon.stub().resolves({ status: 200 } as unknown as NextResponse), + }; + + const req = {} as NextRequest; + + const result = await defineProxy(localeProxy).exec(req, redirect); + + expect(localeProxy.handle).to.not.have.been.called; + expect(result).to.equal(redirect); + }); + it('should pass context to proxies when generateContext is true', async () => { const proxiesContext: ProxiesContext = new Map(); const successfulExecution: { marker: string } & SuccessfulProxyExecution = { diff --git a/packages/nextjs/src/proxy/proxy.ts b/packages/nextjs/src/proxy/proxy.ts index 090f65712f..47ef8e21fe 100644 --- a/packages/nextjs/src/proxy/proxy.ts +++ b/packages/nextjs/src/proxy/proxy.ts @@ -283,6 +283,28 @@ export abstract class ProxyBase extends ProxyHandler { } } +const HTTP_REDIRECT_STATUS_MIN = 300; +const HTTP_REDIRECT_STATUS_MAX = 399; + +const isRedirectStatus = (status: number) => + status >= HTTP_REDIRECT_STATUS_MIN && status <= HTTP_REDIRECT_STATUS_MAX; + +/** + * Returns true when the proxy chain should stop: + * - 403 (e.g. PreviewProxy access denial) + * - redirect via `redirected`, or 3xx status (e.g. RedirectsProxy, next-intl locale negotiation) + * @param {NextResponse} res response + * @returns {boolean} true when remaining handlers should be skipped + */ +function shouldShortCircuitProxyChain(res: NextResponse): boolean { + if (res.status === 403) { + return true; + } + + // Next.js 16 may leave `redirected` false on redirect responses; also check 3xx. + return !!res.redirected || isRedirectStatus(res.status); +} + /** * Define a proxy with a list of proxy handlers * @param {ProxyHandler[]} proxies List of proxy handlers to execute @@ -306,9 +328,8 @@ export const defineProxy = (...proxies: ProxyHandler[]) => { const proxyResponse = await proxies.reduce( (p, proxy) => p.then((res) => { - // Short-circuit the remaining proxies once a previous one - // denied the request (e.g. PreviewProxy returning 403). - if (res.status === 403) return res; + // Short-circuit once a handler denied the request (403) or issued a redirect. + if (shouldShortCircuitProxyChain(res)) return res; return proxy.handle(req, res, proxiesContext); }), From 2a59f3364ebb533f9ee42a8a162b2e98e6c69adb Mon Sep 17 00:00:00 2001 From: sc-naveenhedallaarachchi Date: Thu, 25 Jun 2026 12:50:42 +0530 Subject: [PATCH 2/2] Logic simplified --- packages/nextjs/src/proxy/proxy.ts | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/nextjs/src/proxy/proxy.ts b/packages/nextjs/src/proxy/proxy.ts index 47ef8e21fe..ac96b31344 100644 --- a/packages/nextjs/src/proxy/proxy.ts +++ b/packages/nextjs/src/proxy/proxy.ts @@ -283,26 +283,13 @@ export abstract class ProxyBase extends ProxyHandler { } } -const HTTP_REDIRECT_STATUS_MIN = 300; -const HTTP_REDIRECT_STATUS_MAX = 399; - -const isRedirectStatus = (status: number) => - status >= HTTP_REDIRECT_STATUS_MIN && status <= HTTP_REDIRECT_STATUS_MAX; - /** - * Returns true when the proxy chain should stop: - * - 403 (e.g. PreviewProxy access denial) - * - redirect via `redirected`, or 3xx status (e.g. RedirectsProxy, next-intl locale negotiation) - * @param {NextResponse} res response + * Returns true when the proxy chain should stop (403, redirect, or HTTP 3xx). + * @param {NextResponse} res response from a proxy handler * @returns {boolean} true when remaining handlers should be skipped */ function shouldShortCircuitProxyChain(res: NextResponse): boolean { - if (res.status === 403) { - return true; - } - - // Next.js 16 may leave `redirected` false on redirect responses; also check 3xx. - return !!res.redirected || isRedirectStatus(res.status); + return res.status === 403 || !!res.redirected || (res.status >= 300 && res.status <= 399); } /** @@ -328,8 +315,9 @@ export const defineProxy = (...proxies: ProxyHandler[]) => { const proxyResponse = await proxies.reduce( (p, proxy) => p.then((res) => { - // Short-circuit once a handler denied the request (403) or issued a redirect. - if (shouldShortCircuitProxyChain(res)) return res; + if (shouldShortCircuitProxyChain(res)) { + return res; + } return proxy.handle(req, res, proxiesContext); }),