Skip to content

Commit eff9d5e

Browse files
Add support for Opaque redirects (#1318)
* Add support for Opaque redirects * Fix redirect option * Determine redirect based on `status` property Since we were relying on browser spec which supports a concept of opaque-redirect, the Cloufdlare Workers on the other hand don't suppport certain properties which make this possible. They, instead make the actual status code available in redirect manual mode which is now being used to determine whether the request is redirect. * change a test def * Reduce the brittleness of test * Run prettier of cloudflare-optimizer-scripts * Rewrite Location Header for a redirect back to origin in proxy mode * Handle Location rewrites for non-get requests * styling fix for maybeRewriteRedirectHeader Co-authored-by: Jake Fried <[email protected]> * remove non-useful event.respondWith assertion Co-authored-by: Jake Fried <[email protected]> * use Headers init constructor Co-authored-by: Jake Fried <[email protected]> * clarity in test title Co-authored-by: Jake Fried <[email protected]> * reuse isRedirect and isReverseProxy * Restructure rewrite header logic for non-Get metods Co-authored-by: Jake Fried <[email protected]>
1 parent 90adbd7 commit eff9d5e

File tree

3 files changed

+137
-8
lines changed

3 files changed

+137
-8
lines changed

packages/cloudflare-optimizer-scripts/src/index.js

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,22 @@ async function handleRequest(event, config) {
6666

6767
let request = event.request;
6868
const url = new URL(request.url);
69+
6970
if (isReverseProxy(config)) {
7071
url.hostname = config.proxy.origin;
7172
}
73+
7274
request = new Request(url.toString(), request);
7375

7476
// Immediately return if not GET.
7577
if (request.method !== 'GET') {
76-
return fetch(request);
78+
if (!isReverseProxy(config)) {
79+
return fetch(request, {redirect: 'manual'});
80+
}
81+
82+
const response = await fetch(request, {redirect: 'manual'});
83+
84+
return isRedirect(response) ? rewriteRedirectHeader(response, config) : response;
7785
}
7886

7987
if (config.enableKVCache) {
@@ -85,7 +93,17 @@ async function handleRequest(event, config) {
8593
}
8694
}
8795

88-
const response = await fetch(request);
96+
const response = await fetch(request, {redirect: 'manual'});
97+
98+
// Redirect based statuses should be returned
99+
if (isRedirect(response)) {
100+
if (isReverseProxy(config)) {
101+
return rewriteRedirectHeader(response, config);
102+
}
103+
104+
return response;
105+
}
106+
89107
const clonedResponse = response.clone();
90108
const {headers, status, statusText} = response;
91109

@@ -259,6 +277,44 @@ function resetOptimizerForTesting() {
259277
ampOptimizer = null;
260278
}
261279

280+
/**
281+
* @description Determine whether {Response} is Redirect style
282+
* @param {Response} response
283+
* @returns {boolean}
284+
*/
285+
function isRedirect(response) {
286+
return response.status >= 300 && response.status <= 399;
287+
}
288+
289+
/**
290+
* @param {!Response} response
291+
* @param {!ConfigDef} config
292+
* @description Rewrites the location header to reflect the worker's hostname
293+
*/
294+
function rewriteRedirectHeader(response, config) {
295+
const locationHeader = response.headers.get('location');
296+
297+
if (!locationHeader) {
298+
return response;
299+
}
300+
301+
const parsedLocation = new URL(locationHeader);
302+
303+
if (config.proxy.origin === parsedLocation.hostname) {
304+
parsedLocation.hostname = config.proxy.worker;
305+
const newHeaders = new Headers(response.headers);
306+
newHeaders.set('location', parsedLocation.toString());
307+
308+
return new Response(response.body, {
309+
headers: newHeaders,
310+
status: response.status,
311+
statusText: response.statusText,
312+
});
313+
}
314+
315+
return response;
316+
}
317+
262318
module.exports = {
263319
getOptimizer,
264320
handleEvent,

packages/cloudflare-optimizer-scripts/test/builtins.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,6 @@ class HTMLRewriter {
4545
}
4646
}
4747

48-
module.exports = {Response, HTMLRewriter, Request};
48+
class Headers extends Map {}
49+
50+
module.exports = {Response, HTMLRewriter, Request, Headers};

packages/cloudflare-optimizer-scripts/test/index.test.js

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const {
3030
validateConfiguration,
3131
resetOptimizerForTesting,
3232
} = require('../src/index');
33-
const {Request, Response, HTMLRewriter} = require('./builtins');
33+
const {Request, Response, HTMLRewriter, Headers} = require('./builtins');
3434

3535
beforeEach(() => {
3636
global.fetch = jest.fn();
@@ -42,6 +42,7 @@ beforeEach(() => {
4242
global.Request = Request;
4343
global.Response = Response;
4444
global.HTMLRewriter = HTMLRewriter;
45+
global.Headers = Headers;
4546
});
4647

4748
describe('handleEvent', () => {
@@ -70,11 +71,31 @@ describe('handleEvent', () => {
7071
expect(await event.respondWith.mock.calls[0][0]).toBe(incomingResponse);
7172
});
7273

74+
it('should rewrite Location header for Redirect in response to Non-Get Requests (Proxy Mode)', async () => {
75+
const config = {proxy: {origin: 'test-origin.com', worker: 'test.com'}};
76+
77+
const originResponse = new Response(undefined, {
78+
status: 301,
79+
statusText: '',
80+
headers: new Headers([['location', 'https://test-origin.com/abc']]),
81+
});
82+
83+
global.fetch.mockImplementation(() => Promise.resolve(originResponse));
84+
85+
const request = {url: 'http://test.com', method: 'POST'};
86+
const event = {request, passThroughOnException: jest.fn(), respondWith: jest.fn()};
87+
handleEvent(event, {...defaultConfig, ...config});
88+
89+
const response = await event.respondWith.mock.calls[0][0];
90+
expect(response.status).toBe(301);
91+
expect(response.headers.get('location')).toBe('https://test.com/abc');
92+
});
93+
7394
it('Should proxy through optimizer failures', async () => {
7495
const input = `<html amp><body></body></html>`;
7596
const incomingResponse = getResponse(input);
7697
global.fetch.mockReturnValue(incomingResponse);
77-
AmpOptimizer.transformHtmlSpy.mockReturnValue(Promise.reject('Fail.'));
98+
AmpOptimizer.transformHtmlSpy.mockImplementation(() => Promise.reject('Fail.'));
7899

79100
const output = await getOutput('http://text.com');
80101
expect(output).toBe(input);
@@ -105,11 +126,58 @@ describe('handleEvent', () => {
105126
expect(output).toBe(`transformed-${input}`);
106127
});
107128

129+
it('should passthrough opaque redirects unmodified (non-proxy mode)', async () => {
130+
const mockedResponse = new Response('', {
131+
status: 302,
132+
statusText: '',
133+
});
134+
135+
const event = {
136+
request: {method: 'GET', url: 'https://example.com/'},
137+
respondWith: jest.fn(),
138+
passThroughOnException: jest.fn(),
139+
};
140+
141+
global.fetch.mockImplementation(() => Promise.resolve(mockedResponse));
142+
143+
handleEvent(event, defaultConfig);
144+
expect(await event.respondWith.mock.calls[0][0]).toBe(mockedResponse);
145+
});
146+
147+
it('should rewrite location header for redirects to origin in proxy mode', async () => {
148+
const config = {proxy: {worker: 'test.com', origin: 'test-origin.com'}};
149+
150+
const mockedResponse = new Response('', {
151+
status: 302,
152+
statusText: '',
153+
headers: new Headers([['location', 'https://test-origin.com/abc']]),
154+
});
155+
156+
const event = {
157+
request: {method: 'GET', url: 'https://test.com/'},
158+
respondWith: jest.fn(),
159+
passThroughOnException: jest.fn(),
160+
};
161+
162+
global.fetch.mockImplementation(() => Promise.resolve(mockedResponse));
163+
164+
handleEvent(event, {...defaultConfig, ...config});
165+
166+
const response = await event.respondWith.mock.calls[0][0];
167+
168+
expect(response.status).toBe(mockedResponse.status);
169+
expect(response.headers.get('location')).toBe('https://test.com/abc');
170+
expect(event.respondWith).toHaveBeenCalledTimes(1);
171+
});
172+
108173
it('Should passthrough request to origin in request interceptor mode', async () => {
109174
const input = `<html amp><body></body></html>`;
110175
global.fetch.mockReturnValue(getResponse(input));
111176
await getOutput('http://test.com');
112-
expect(global.fetch).toBeCalledWith({url: 'http://test.com/', method: 'GET'});
177+
expect(global.fetch).toBeCalledWith(
178+
{url: 'http://test.com/', method: 'GET'},
179+
{redirect: 'manual'}
180+
);
113181
});
114182

115183
it('Should modify request url for reverse-proxy', async () => {
@@ -118,7 +186,10 @@ describe('handleEvent', () => {
118186
global.fetch.mockReturnValue(getResponse(input));
119187

120188
await getOutput('http://test.com', config);
121-
expect(global.fetch).toBeCalledWith({url: 'http://test-origin.com/', method: 'GET'});
189+
expect(global.fetch).toBeCalledWith(
190+
{url: 'http://test-origin.com/', method: 'GET'},
191+
{redirect: 'manual'}
192+
);
122193
});
123194

124195
it('should call enable passThroughOnException', async () => {
@@ -180,7 +251,7 @@ describe('getAmpOptimizer', () => {
180251

181252
function getResponse(html, {contentType} = {contentType: 'text/html'}) {
182253
return new Response(html, {
183-
headers: {get: () => contentType},
254+
headers: new Headers([['content-type', contentType]]),
184255
status: 200,
185256
statusText: '200',
186257
});

0 commit comments

Comments
 (0)