Skip to content

Commit 0da688b

Browse files
fix(apostrophe-astro): fix a bug where redirect responses drop headers (#5518)
* fix(apostrophe-astro): fix a bug where redirect responses drop headers * fix(apostrophe-astro) fix a bug where the response body is dropped but the describing headers are not
1 parent b011cdd commit 0da688b

5 files changed

Lines changed: 99 additions & 11 deletions

File tree

.changeset/late-comics-chew.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@apostrophecms/apostrophe-astro": patch
3+
---
4+
5+
Fixed a bug where redirect responses proxied through `aposProxy.js` (e.g. OAuth login callbacks) lost every header except `Location`. This affected setups where Apostrophe is only reachable through the Astro proxy (e.g. a sidecar deployment), causing the session `Set-Cookie` header from an OAuth callback redirect to be silently dropped and the login to appear to fail.
6+
Included fix to `aposResponse.js` to remove entity headers describing the body when dropping the body (e.g. redirects) which may otherwise cause strict clients to hang waiting for bytes.

packages/apostrophe-astro/endpoints/aposProxy.js

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,8 @@
1-
import aposResponse from "../lib/aposResponse";
1+
import aposResponse from "../lib/aposResponse.js";
22

3-
export async function ALL({ params, request, redirect }) {
3+
export async function ALL({ request }) {
44
try {
5-
const response = await aposResponse(request);
6-
if ([301, 302, 307, 308].includes(response.status)) {
7-
return redirect(response.headers.get('location'), response.status);
8-
}
9-
return response;
5+
return await aposResponse(request);
106
} catch (e) {
117
return new Response(e.message, { status: 500 });
128
}

packages/apostrophe-astro/lib/aposResponse.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,16 @@ export default async function aposResponse(req) {
8383

8484
const { headers, statusCode, ...rest } = res;
8585

86-
// Statuses whose body we never send to the client: 204/304 carry none,
87-
// and redirects (301/302/307/308) become a fresh Astro redirect built
88-
// from the Location header in aposProxy. Dump the undici body so its
89-
// socket returns to the pool instead of being held open until GC.
86+
// Statuses we forward with no body: 204/304 carry none, and for
87+
// redirects (301/302/307/308) Location + Set-Cookie are all the client
88+
// needs. Dump the undici body so its socket returns to the pool, and
89+
// strip the entity headers that described it - a stale Content-Length
90+
// would make the client wait for bytes that never arrive.
9091
if ([204, 304, 301, 302, 307, 308].includes(statusCode)) {
9192
await res.body.dump().catch(() => {});
93+
responseHeaders.delete('content-length');
94+
responseHeaders.delete('content-encoding');
95+
responseHeaders.delete('transfer-encoding');
9296
return new Response(null, { ...rest, status: statusCode, headers: responseHeaders });
9397
}
9498

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import assert from 'node:assert/strict';
2+
import esmock from 'esmock';
3+
4+
async function loadAposProxy(mockResponse) {
5+
return esmock('../../endpoints/aposProxy.js', {
6+
'../../lib/aposResponse.js': {
7+
default: async () => mockResponse
8+
}
9+
});
10+
}
11+
12+
function makeRequest(url = 'http://localhost:4321/login/callback') {
13+
return new Request(url);
14+
}
15+
16+
describe('aposProxy', () => {
17+
it('forwards redirect responses without dropping non-Location headers', async () => {
18+
// Regression test: Astro's own `redirect()` helper only keeps the
19+
// Location header, which silently drops the session Set-Cookie header
20+
// an OAuth callback relies on to mark the browser as authenticated.
21+
const headers = new Headers();
22+
headers.set('location', '/dashboard');
23+
headers.append('set-cookie', 'apos.sid=abc123; Path=/; HttpOnly');
24+
headers.set('x-custom-auth', 'issued');
25+
const mockResponse = new Response(null, { status: 302, headers });
26+
27+
const { ALL } = await loadAposProxy(mockResponse);
28+
const res = await ALL({ request: makeRequest() });
29+
30+
assert.equal(res.status, 302);
31+
assert.equal(res.headers.get('location'), '/dashboard');
32+
assert.equal(res.headers.get('set-cookie'), 'apos.sid=abc123; Path=/; HttpOnly');
33+
assert.equal(res.headers.get('x-custom-auth'), 'issued');
34+
});
35+
36+
it('passes non-redirect responses through unchanged', async () => {
37+
const mockResponse = new Response('{"ok":true}', {
38+
status: 200,
39+
headers: { 'content-type': 'application/json' }
40+
});
41+
42+
const { ALL } = await loadAposProxy(mockResponse);
43+
const res = await ALL({ request: makeRequest() });
44+
45+
assert.equal(res.status, 200);
46+
assert.equal(await res.text(), '{"ok":true}');
47+
});
48+
49+
it('returns a 500 text response when aposResponse throws', async () => {
50+
const { ALL } = await esmock('../../endpoints/aposProxy.js', {
51+
'../../lib/aposResponse.js': {
52+
default: async () => { throw new Error('boom'); }
53+
}
54+
});
55+
56+
const res = await ALL({ request: makeRequest() });
57+
assert.equal(res.status, 500);
58+
assert.equal(await res.text(), 'boom');
59+
});
60+
});

packages/apostrophe-astro/test/lib/aposResponse.test.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,28 @@ describe('aposResponse', () => {
153153
assert.equal(res.body, null);
154154
});
155155
}
156+
157+
for (const status of [ 204, 304, 301, 302, 307, 308 ]) {
158+
it(`strips entity headers describing the dropped body for ${status}`, async () => {
159+
const { default: aposResponse } = await loadAposResponse({}, async () => ({
160+
statusCode: status,
161+
headers: {
162+
location: '/dashboard',
163+
'set-cookie': 'apos.sid=abc123; Path=/; HttpOnly',
164+
'content-length': '1234',
165+
'content-encoding': 'gzip',
166+
'transfer-encoding': 'chunked'
167+
},
168+
body: makeBody('')
169+
}));
170+
const res = await aposResponse(makeRequest());
171+
assert.equal(res.status, status);
172+
assert.equal(res.headers.get('set-cookie'), 'apos.sid=abc123; Path=/; HttpOnly');
173+
assert.equal(res.headers.get('content-length'), null);
174+
assert.equal(res.headers.get('content-encoding'), null);
175+
assert.equal(res.headers.get('transfer-encoding'), null);
176+
});
177+
}
156178
});
157179

158180
describe('normal response', () => {

0 commit comments

Comments
 (0)