Skip to content

Commit 8ef4585

Browse files
fix: internal header leak
1 parent 862d77b commit 8ef4585

6 files changed

Lines changed: 115 additions & 57 deletions

File tree

.changeset/smart-pillows-chew.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Fixes a case where internal headers may leak when rendering error pages

packages/astro/src/core/app/base.ts

Lines changed: 75 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ import type { Pipeline } from '../base-pipeline.js';
1515
import {
1616
clientAddressSymbol,
1717
DEFAULT_404_COMPONENT,
18+
NOOP_MIDDLEWARE_HEADER,
1819
REROUTABLE_STATUS_CODES,
1920
REROUTE_DIRECTIVE_HEADER,
2021
responseSentSymbol,
2122
REWRITE_DIRECTIVE_HEADER_KEY,
23+
ROUTE_TYPE_HEADER,
2224
} from '../constants.js';
2325
import { getSetCookiesFromResponse } from '../cookies/index.js';
2426
import { AstroError, AstroErrorData } from '../errors/index.js';
@@ -84,9 +86,17 @@ export interface RenderOptions {
8486
routeData?: RouteData;
8587
}
8688

87-
export interface RenderErrorOptions {
88-
locals?: App.Locals;
89-
routeData?: RouteData;
89+
type RequiredRenderOptions = Required<RenderOptions>;
90+
91+
interface ResolvedRenderOptions {
92+
addCookieHeader: RequiredRenderOptions['addCookieHeader'];
93+
clientAddress: RequiredRenderOptions['clientAddress'] | undefined;
94+
prerenderedErrorPageFetch: RequiredRenderOptions['prerenderedErrorPageFetch'] | undefined;
95+
locals: RequiredRenderOptions['locals'] | undefined;
96+
routeData: RequiredRenderOptions['routeData'] | undefined;
97+
}
98+
99+
export interface RenderErrorOptions extends ResolvedRenderOptions {
90100
response?: Response;
91101
status: 404 | 500;
92102
/**
@@ -97,8 +107,6 @@ export interface RenderErrorOptions {
97107
* Allows passing an error to 500.astro. It will be available through `Astro.props.error`.
98108
*/
99109
error?: unknown;
100-
clientAddress: string | undefined;
101-
prerenderedErrorPageFetch: ((url: ErrorPagePath) => Promise<Response>) | undefined;
102110
}
103111

104112
type ErrorPagePath =
@@ -355,19 +363,23 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {
355363
return pathname;
356364
}
357365

358-
public async render(request: Request, renderOptions?: RenderOptions): Promise<Response> {
366+
public async render(
367+
request: Request,
368+
{
369+
addCookieHeader = false,
370+
clientAddress = Reflect.get(request, clientAddressSymbol),
371+
locals,
372+
prerenderedErrorPageFetch = fetch,
373+
routeData,
374+
}: RenderOptions = {},
375+
): Promise<Response> {
359376
const timeStart = performance.now();
360-
let routeData: RouteData | undefined = renderOptions?.routeData;
361-
let locals: object | undefined;
362-
let clientAddress: string | undefined;
363-
let addCookieHeader: boolean | undefined;
364377
const url = new URL(request.url);
365378
const redirect = this.redirectTrailingSlash(url.pathname);
366-
const prerenderedErrorPageFetch = renderOptions?.prerenderedErrorPageFetch ?? fetch;
367379

368380
if (redirect !== url.pathname) {
369381
const status = request.method === 'GET' ? 301 : 308;
370-
return new Response(
382+
const response = new Response(
371383
redirectTemplate({
372384
status,
373385
relativeLocation: url.pathname,
@@ -381,13 +393,10 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {
381393
},
382394
},
383395
);
396+
this.#prepareResponse(response, { addCookieHeader });
397+
return response;
384398
}
385399

386-
addCookieHeader = renderOptions?.addCookieHeader;
387-
clientAddress = renderOptions?.clientAddress ?? Reflect.get(request, clientAddressSymbol);
388-
routeData = renderOptions?.routeData;
389-
locals = renderOptions?.locals;
390-
391400
if (routeData) {
392401
this.logger.debug(
393402
'router',
@@ -397,15 +406,26 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {
397406
this.logger.debug('router', 'RouteData');
398407
this.logger.debug('router', routeData);
399408
}
409+
410+
const resolvedRenderOptions: ResolvedRenderOptions = {
411+
addCookieHeader,
412+
clientAddress,
413+
prerenderedErrorPageFetch,
414+
locals,
415+
routeData,
416+
};
417+
400418
if (locals) {
401419
if (typeof locals !== 'object') {
402420
const error = new AstroError(AstroErrorData.LocalsNotAnObject);
403421
this.logger.error(null, error.stack!);
404422
return this.renderError(request, {
423+
...resolvedRenderOptions,
424+
// If locals are invalid, we don't want to include them when
425+
// rendering the error page
426+
locals: undefined,
405427
status: 500,
406428
error,
407-
clientAddress,
408-
prerenderedErrorPageFetch: prerenderedErrorPageFetch,
409429
});
410430
}
411431
}
@@ -434,10 +454,8 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {
434454
this.logger.debug('router', "Astro hasn't found routes that match " + request.url);
435455
this.logger.debug('router', "Here's the available routes:\n", this.manifestData);
436456
return this.renderError(request, {
437-
locals,
457+
...resolvedRenderOptions,
438458
status: 404,
439-
clientAddress,
440-
prerenderedErrorPageFetch: prerenderedErrorPageFetch,
441459
});
442460
}
443461
const pathname = this.getPathnameFromRequest(request);
@@ -472,11 +490,9 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {
472490
} catch (err: any) {
473491
this.logger.error(null, err.stack || err.message || String(err));
474492
return this.renderError(request, {
475-
locals,
493+
...resolvedRenderOptions,
476494
status: 500,
477495
error: err,
478-
clientAddress,
479-
prerenderedErrorPageFetch: prerenderedErrorPageFetch,
480496
});
481497
} finally {
482498
await session?.[PERSIST_SYMBOL]();
@@ -490,30 +506,39 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {
490506
response.headers.get(REROUTE_DIRECTIVE_HEADER) !== 'no'
491507
) {
492508
return this.renderError(request, {
493-
locals,
509+
...resolvedRenderOptions,
494510
response,
495511
status: response.status as 404 | 500,
496512
// We don't have an error to report here. Passing null means we pass nothing intentionally
497513
// while undefined means there's no error
498514
error: response.status === 500 ? null : undefined,
499-
clientAddress,
500-
prerenderedErrorPageFetch: prerenderedErrorPageFetch,
501515
});
502516
}
503517

518+
this.#prepareResponse(response, { addCookieHeader });
519+
return response;
520+
}
521+
522+
#prepareResponse(response: Response, { addCookieHeader }: { addCookieHeader: boolean }): void {
504523
// We remove internally-used header before we send the response to the user agent.
505-
if (response.headers.has(REROUTE_DIRECTIVE_HEADER)) {
506-
response.headers.delete(REROUTE_DIRECTIVE_HEADER);
524+
for (const headerName of [
525+
REROUTE_DIRECTIVE_HEADER,
526+
REWRITE_DIRECTIVE_HEADER_KEY,
527+
NOOP_MIDDLEWARE_HEADER,
528+
ROUTE_TYPE_HEADER,
529+
]) {
530+
if (response.headers.has(headerName)) {
531+
response.headers.delete(headerName);
532+
}
507533
}
508534

509535
if (addCookieHeader) {
510-
for (const setCookieHeaderValue of BaseApp.getSetCookieFromResponse(response)) {
536+
for (const setCookieHeaderValue of getSetCookiesFromResponse(response)) {
511537
response.headers.append('set-cookie', setCookieHeaderValue);
512538
}
513539
}
514540

515541
Reflect.set(response, responseSentSymbol, true);
516-
return response;
517542
}
518543

519544
setCookieHeaders(response: Response) {
@@ -540,13 +565,11 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {
540565
public async renderError(
541566
request: Request,
542567
{
543-
locals,
544568
status,
545569
response: originalResponse,
546570
skipMiddleware = false,
547571
error,
548-
clientAddress,
549-
prerenderedErrorPageFetch,
572+
...resolvedRenderOptions
550573
}: RenderErrorOptions,
551574
): Promise<Response> {
552575
const errorRoutePath = `/${status}${this.manifest.trailingSlash === 'always' ? '/' : ''}`;
@@ -556,8 +579,13 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {
556579
if (errorRouteData.prerender) {
557580
const maybeDotHtml = errorRouteData.route.endsWith(`/${status}`) ? '.html' : '';
558581
const statusURL = new URL(`${this.baseWithoutTrailingSlash}/${status}${maybeDotHtml}`, url);
559-
if (statusURL.toString() !== request.url && prerenderedErrorPageFetch) {
560-
const response = await prerenderedErrorPageFetch(statusURL.toString() as ErrorPagePath);
582+
if (
583+
statusURL.toString() !== request.url &&
584+
resolvedRenderOptions.prerenderedErrorPageFetch
585+
) {
586+
const response = await resolvedRenderOptions.prerenderedErrorPageFetch(
587+
statusURL.toString() as ErrorPagePath,
588+
);
561589

562590
// In order for the response of the remote to be usable as a response
563591
// for this request, it needs to have our status code in the response
@@ -571,36 +599,38 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {
571599
// not match the body we provide and need to be removed.
572600
const override = { status, removeContentEncodingHeaders: true };
573601

574-
return this.mergeResponses(response, originalResponse, override);
602+
const newResponse = this.mergeResponses(response, originalResponse, override);
603+
this.#prepareResponse(newResponse, resolvedRenderOptions);
604+
return newResponse;
575605
}
576606
}
577607
const mod = await this.pipeline.getComponentByRoute(errorRouteData);
578608
let session: AstroSession | undefined;
579609
try {
580610
const renderContext = await this.createRenderContext({
581-
locals,
611+
locals: resolvedRenderOptions.locals,
582612
pipeline: this.pipeline,
583613
skipMiddleware,
584614
pathname: this.getPathnameFromRequest(request),
585615
request,
586616
routeData: errorRouteData,
587617
status,
588618
props: { error },
589-
clientAddress,
619+
clientAddress: resolvedRenderOptions.clientAddress,
590620
});
591621
session = renderContext.session;
592622
const response = await renderContext.render(mod);
593-
return this.mergeResponses(response, originalResponse);
623+
const newResponse = this.mergeResponses(response, originalResponse);
624+
this.#prepareResponse(newResponse, resolvedRenderOptions);
625+
return newResponse;
594626
} catch {
595627
// Middleware may be the cause of the error, so we try rendering 404/500.astro without it.
596628
if (skipMiddleware === false) {
597629
return this.renderError(request, {
598-
locals,
630+
...resolvedRenderOptions,
599631
status,
600632
response: originalResponse,
601633
skipMiddleware: true,
602-
clientAddress,
603-
prerenderedErrorPageFetch,
604634
});
605635
}
606636
} finally {
@@ -609,7 +639,7 @@ export abstract class BaseApp<P extends Pipeline = AppPipeline> {
609639
}
610640

611641
const response = this.mergeResponses(new Response(null, { status }), originalResponse);
612-
Reflect.set(response, responseSentSymbol, true);
642+
this.#prepareResponse(response, resolvedRenderOptions);
613643
return response;
614644
}
615645

packages/astro/src/core/app/dev/app.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,13 @@ export class DevApp extends BaseApp<NonRunnablePipeline> {
7676

7777
async renderError(
7878
request: Request,
79-
{ locals, skipMiddleware = false, error, clientAddress, status }: RenderErrorOptions,
79+
{
80+
skipMiddleware = false,
81+
error,
82+
status,
83+
response: _response,
84+
...resolvedRenderOptions
85+
}: RenderErrorOptions,
8086
): Promise<Response> {
8187
// we always throw when we have Astro errors around the middleware
8288
if (
@@ -90,13 +96,13 @@ export class DevApp extends BaseApp<NonRunnablePipeline> {
9096
try {
9197
const preloadedComponent = await this.pipeline.getComponentByRoute(routeData);
9298
const renderContext = await this.createRenderContext({
93-
locals,
99+
locals: resolvedRenderOptions.locals,
94100
pipeline: this.pipeline,
95-
pathname: await this.getPathnameFromRequest(request),
101+
pathname: this.getPathnameFromRequest(request),
96102
skipMiddleware,
97103
request,
98104
routeData,
99-
clientAddress,
105+
clientAddress: resolvedRenderOptions.clientAddress,
100106
status,
101107
shouldInjectCspMetaTags: false,
102108
});
@@ -112,8 +118,7 @@ export class DevApp extends BaseApp<NonRunnablePipeline> {
112118
} catch (_err) {
113119
if (skipMiddleware === false) {
114120
return this.renderError(request, {
115-
clientAddress: undefined,
116-
prerenderedErrorPageFetch: fetch,
121+
...resolvedRenderOptions,
117122
status: 500,
118123
skipMiddleware: true,
119124
error: _err,

packages/astro/src/vite-plugin-app/app.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,13 @@ export class AstroServerApp extends BaseApp<RunnablePipeline> {
234234

235235
async renderError(
236236
request: Request,
237-
{ locals, skipMiddleware = false, error, clientAddress, status }: RenderErrorOptions,
237+
{
238+
skipMiddleware = false,
239+
error,
240+
status,
241+
response: _response,
242+
...resolvedRenderOptions
243+
}: RenderErrorOptions,
238244
): Promise<Response> {
239245
// we always throw when we have Astro errors around the middleware
240246
if (
@@ -248,13 +254,13 @@ export class AstroServerApp extends BaseApp<RunnablePipeline> {
248254
try {
249255
const preloadedComponent = await this.pipeline.getComponentByRoute(routeData);
250256
const renderContext = await this.createRenderContext({
251-
locals,
257+
locals: resolvedRenderOptions.locals,
252258
pipeline: this.pipeline,
253-
pathname: await this.getPathnameFromRequest(request),
259+
pathname: this.getPathnameFromRequest(request),
254260
skipMiddleware,
255261
request,
256262
routeData,
257-
clientAddress,
263+
clientAddress: resolvedRenderOptions.clientAddress,
258264
status,
259265
shouldInjectCspMetaTags: !!this.manifest.csp,
260266
});
@@ -270,8 +276,7 @@ export class AstroServerApp extends BaseApp<RunnablePipeline> {
270276
} catch (_err) {
271277
if (skipMiddleware === false) {
272278
return this.renderError(request, {
273-
clientAddress: undefined,
274-
prerenderedErrorPageFetch: fetch,
279+
...resolvedRenderOptions,
275280
status: 500,
276281
skipMiddleware: true,
277282
error: _err,

packages/astro/test/units/app/error-pages.test.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ describe('App render error pages', () => {
4545

4646
assert.equal(response.status, 500);
4747
assert.equal(response.headers.get('x-debug'), '1234');
48+
assert.equal(
49+
[...response.headers.keys()].some((e) => e.startsWith('X-Astro-')),
50+
false,
51+
);
4852
assert.match(await response.text(), /oops/);
4953
});
5054

packages/astro/test/units/app/response.test.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,4 +142,13 @@ describe('Using Astro.response in SSR', () => {
142142
assert.equal(headers.get('four-five'), 'six');
143143
assert.equal(headers.get('Cache-Control'), 'max-age=0, s-maxage=86400');
144144
});
145+
146+
it('Removes internal headers', async () => {
147+
const request = new Request('http://example.com/some-header');
148+
const response = await app.render(request);
149+
assert.equal(
150+
[...response.headers.keys()].some((e) => e.startsWith('X-Astro-')),
151+
false,
152+
);
153+
});
145154
});

0 commit comments

Comments
 (0)