From 918d3949f3b92b1ae46dadd77cfb1404869c50bd Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 4 Mar 2026 11:34:18 +0000 Subject: [PATCH 1/7] fix(session): reject unrecognized session IDs from cookies (#15752) When a session ID is supplied via cookie but has no corresponding server-side data, generate a new ID instead of accepting the unknown one. This prevents session fixation where an attacker pre-sets a known session ID in the victim's cookie. --- .changeset/red-quail-bite.md | 5 ++ packages/astro/src/core/session/runtime.ts | 23 +++++- .../fixtures/sessions/src/pages/login-safe.ts | 8 ++ .../test/fixtures/sessions/src/pages/login.ts | 7 ++ packages/astro/test/sessions.test.js | 82 +++++++++++++++++++ 5 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 .changeset/red-quail-bite.md create mode 100644 packages/astro/test/fixtures/sessions/src/pages/login-safe.ts create mode 100644 packages/astro/test/fixtures/sessions/src/pages/login.ts diff --git a/.changeset/red-quail-bite.md b/.changeset/red-quail-bite.md new file mode 100644 index 000000000000..0dca2db5d325 --- /dev/null +++ b/.changeset/red-quail-bite.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes an issue where a session ID from a cookie with no matching server-side data was accepted as-is. The session now generates a new ID when the cookie value has no corresponding storage entry. diff --git a/packages/astro/src/core/session/runtime.ts b/packages/astro/src/core/session/runtime.ts index d814ca95e528..a72f12ffa232 100644 --- a/packages/astro/src/core/session/runtime.ts +++ b/packages/astro/src/core/session/runtime.ts @@ -53,6 +53,8 @@ export class AstroSession { #dirty = false; // Whether the session cookie has been set. #cookieSet = false; + // Whether the session ID was sourced from a client cookie rather than freshly generated. + #sessionIDFromCookie = false; // The local data is "partial" if it has not been loaded from storage yet and only // contains values that have been set or deleted in-memory locally. // We do this to avoid the need to block on loading data when it is only being set. @@ -242,6 +244,7 @@ export class AstroSession { // Create new session this.#sessionID = crypto.randomUUID(); + this.#sessionIDFromCookie = false; this.#data = data; this.#dirty = true; await this.#setCookie(); @@ -349,6 +352,16 @@ export class AstroSession { // We stored this as a devalue string, but unstorage will have parsed it as JSON const raw = await storage.get(this.#ensureSessionID()); if (!raw) { + if (this.#sessionIDFromCookie) { + // The session ID was supplied by the client cookie but has no corresponding + // server-side data. Generate a new server-controlled ID rather than + // accepting an unrecognized value from the client. + this.#sessionID = crypto.randomUUID(); + this.#sessionIDFromCookie = false; + if (this.#cookieSet) { + await this.#setCookie(); + } + } // If there is no existing data in storage we don't need to merge anything // and can just return the existing local data. return this.#data; @@ -404,7 +417,15 @@ export class AstroSession { * Returns the session ID, generating a new one if it does not exist. */ #ensureSessionID() { - this.#sessionID ??= this.#cookies.get(this.#cookieName)?.value ?? crypto.randomUUID(); + if (!this.#sessionID) { + const cookieValue = this.#cookies.get(this.#cookieName)?.value; + if (cookieValue) { + this.#sessionID = cookieValue; + this.#sessionIDFromCookie = true; + } else { + this.#sessionID = crypto.randomUUID(); + } + } return this.#sessionID; } diff --git a/packages/astro/test/fixtures/sessions/src/pages/login-safe.ts b/packages/astro/test/fixtures/sessions/src/pages/login-safe.ts new file mode 100644 index 000000000000..6f5c69ca28a0 --- /dev/null +++ b/packages/astro/test/fixtures/sessions/src/pages/login-safe.ts @@ -0,0 +1,8 @@ +import type { APIRoute } from 'astro'; + +export const POST: APIRoute = async (context) => { + const body = await context.request.json() as any; + await context.session.regenerate(); + context.session.set('user', body.username); + return Response.json({ username: body.username }); +}; diff --git a/packages/astro/test/fixtures/sessions/src/pages/login.ts b/packages/astro/test/fixtures/sessions/src/pages/login.ts new file mode 100644 index 000000000000..025cd8fa3856 --- /dev/null +++ b/packages/astro/test/fixtures/sessions/src/pages/login.ts @@ -0,0 +1,7 @@ +import type { APIRoute } from 'astro'; + +export const POST: APIRoute = async (context) => { + const body = await context.request.json() as any; + context.session.set('user', body.username); + return Response.json({ username: body.username }); +}; diff --git a/packages/astro/test/sessions.test.js b/packages/astro/test/sessions.test.js index b65201e43d60..0ea12c40e3ad 100644 --- a/packages/astro/test/sessions.test.js +++ b/packages/astro/test/sessions.test.js @@ -101,6 +101,88 @@ describe('Astro.session', () => { ); }); + it('generates a new session ID when cookie value has no server-side data', async () => { + const unknownId = 'nonexistent-session-id-12345'; + const response = await fetchResponse('/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + cookie: `astro-session=${unknownId}`, + }, + body: JSON.stringify({ username: 'testuser' }), + }); + assert.equal(response.ok, true); + const headers = Array.from(app.setCookieHeaders(response)); + const sessionId = headers[0].split(';')[0].split('=')[1]; + // A new ID should be generated since the cookie value had no stored data + assert.notEqual(sessionId, unknownId, 'Should not adopt a session ID with no stored data'); + + // The original ID should not give access to the new session's data + const secondResponse = await fetchResponse('/update', { + method: 'GET', + headers: { + cookie: `astro-session=${unknownId}`, + }, + }); + const secondData = await secondResponse.json(); + assert.equal(secondData.previousValue, 'none', 'Original ID should not have session data'); + }); + + it('preserves session ID when cookie value has existing server-side data', async () => { + const firstResponse = await fetchResponse('/update'); + const firstHeaders = Array.from(app.setCookieHeaders(firstResponse)); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + + const secondResponse = await fetchResponse('/update', { + method: 'GET', + headers: { + cookie: `astro-session=${firstSessionId}`, + }, + }); + const secondHeaders = Array.from(app.setCookieHeaders(secondResponse)); + const secondSessionId = secondHeaders[0].split(';')[0].split('=')[1]; + assert.equal(secondSessionId, firstSessionId, 'Valid session ID should be preserved'); + }); + + it('regenerate() creates a new ID and cleans up the old session', async () => { + const firstResponse = await fetchResponse('/update', { + method: 'GET', + }); + const firstHeaders = Array.from(app.setCookieHeaders(firstResponse)); + const firstSessionId = firstHeaders[0].split(';')[0].split('=')[1]; + + const secondResponse = await fetchResponse('/login-safe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + cookie: `astro-session=${firstSessionId}`, + }, + body: JSON.stringify({ username: 'testuser' }), + }); + assert.equal(secondResponse.ok, true); + const secondHeaders = Array.from(app.setCookieHeaders(secondResponse)); + const secondSessionIds = secondHeaders + .filter((h) => h.startsWith('astro-session=')) + .map((h) => h.split(';')[0].split('=')[1]); + const secondSessionId = secondSessionIds[secondSessionIds.length - 1]; + + assert.notEqual(secondSessionId, firstSessionId, 'regenerate() should create new ID'); + + // Old session ID should no longer have data after regeneration + const thirdResponse = await fetchResponse('/update', { + method: 'GET', + headers: { + cookie: `astro-session=${firstSessionId}`, + }, + }); + const thirdData = await thirdResponse.json(); + assert.equal( + thirdData.previousValue, + 'none', + 'Old session ID should have no data after regeneration', + ); + }); + it('can load a session by ID', async () => { const firstResponse = await fetchResponse('/_actions/addToCart', { method: 'POST', From 20b05c042bde561f53d47348fd4cb2ec478bca23 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Wed, 4 Mar 2026 07:59:53 -0500 Subject: [PATCH 2/7] fix(node): harden static file handler path resolution (#15745) --- .changeset/soft-vans-ask.md | 5 ++ .../integrations/node/src/serve-static.ts | 33 +++++++++-- .../units/serve-static-path-traversal.test.js | 59 +++++++++++++++++++ 3 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 .changeset/soft-vans-ask.md create mode 100644 packages/integrations/node/test/units/serve-static-path-traversal.test.js diff --git a/.changeset/soft-vans-ask.md b/.changeset/soft-vans-ask.md new file mode 100644 index 000000000000..5f91de4aa7df --- /dev/null +++ b/.changeset/soft-vans-ask.md @@ -0,0 +1,5 @@ +--- +'@astrojs/node': patch +--- + +Hardens static file handler path resolution to ensure resolved paths stay within the client directory diff --git a/packages/integrations/node/src/serve-static.ts b/packages/integrations/node/src/serve-static.ts index 917a24db5c6c..34843e93e2ce 100644 --- a/packages/integrations/node/src/serve-static.ts +++ b/packages/integrations/node/src/serve-static.ts @@ -8,6 +8,32 @@ import { resolveClientDir } from './shared.js'; import type { NodeAppHeadersJson, Options } from './types.js'; import { createRequest } from 'astro/app/node'; +/** + * Resolves a URL path to a filesystem path within the client directory, + * and checks whether it is a directory. + * + * Returns `isDirectory: false` if the resolved path escapes the client root + * (e.g. via `..` path traversal segments). + */ +export function resolveStaticPath(client: string, urlPath: string) { + const filePath = path.join(client, urlPath); + const resolved = path.resolve(filePath); + const resolvedClient = path.resolve(client); + + // Prevent path traversal: if the resolved path is outside the client + // directory, treat it as non-existent rather than probing the filesystem. + if (resolved !== resolvedClient && !resolved.startsWith(resolvedClient + path.sep)) { + return { filePath: resolved, isDirectory: false }; + } + + let isDirectory = false; + try { + isDirectory = fs.lstatSync(filePath).isDirectory(); + } catch {} + + return { filePath: resolved, isDirectory }; +} + /** * Creates a Node.js http listener for static files and prerendered pages. * In standalone mode, the static handler is queried first for the static files. @@ -32,12 +58,7 @@ export function createStaticHandler( } const [urlPath, urlQuery] = fullUrl.split('?'); - const filePath = path.join(client, app.removeBase(urlPath)); - - let isDirectory = false; - try { - isDirectory = fs.lstatSync(filePath).isDirectory(); - } catch {} + const { isDirectory } = resolveStaticPath(client, app.removeBase(urlPath)); const hasSlash = urlPath.endsWith('/'); let pathname = urlPath; diff --git a/packages/integrations/node/test/units/serve-static-path-traversal.test.js b/packages/integrations/node/test/units/serve-static-path-traversal.test.js new file mode 100644 index 000000000000..6265450330a4 --- /dev/null +++ b/packages/integrations/node/test/units/serve-static-path-traversal.test.js @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, it, before, after } from 'node:test'; +import { resolveStaticPath } from '../../dist/serve-static.js'; + +describe('resolveStaticPath', () => { + let tmpRoot; + let clientDir; + + before(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'astro-test-')); + clientDir = path.join(tmpRoot, 'client'); + fs.mkdirSync(clientDir); + fs.mkdirSync(path.join(clientDir, 'assets')); + fs.writeFileSync(path.join(clientDir, 'index.html'), '

hello

'); + fs.mkdirSync(path.join(tmpRoot, 'secret')); + }); + + after(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('detects a subdirectory within client root', () => { + const result = resolveStaticPath(clientDir, '/assets'); + assert.equal(result.isDirectory, true); + }); + + it('returns false for a non-existent path', () => { + const result = resolveStaticPath(clientDir, '/nope'); + assert.equal(result.isDirectory, false); + }); + + it('returns false for a sibling directory via ../', () => { + const result = resolveStaticPath(clientDir, '/../secret'); + assert.equal(result.isDirectory, false); + }); + + it('returns false for the parent directory', () => { + const result = resolveStaticPath(clientDir, '/..'); + assert.equal(result.isDirectory, false); + }); + + it('returns false for deep .. traversal', () => { + const result = resolveStaticPath(clientDir, '/../../../../../../../usr'); + assert.equal(result.isDirectory, false); + }); + + it('returns false for .. traversal with trailing slash', () => { + const result = resolveStaticPath(clientDir, '/../secret/'); + assert.equal(result.isDirectory, false); + }); + + it('detects the client root itself', () => { + const result = resolveStaticPath(clientDir, '/'); + assert.equal(result.isDirectory, true); + }); +}); From 8fae7aeaa34e067e5405ac75a73a16dc6931a4a0 Mon Sep 17 00:00:00 2001 From: Rafael Yasuhide Sudo Date: Wed, 4 Mar 2026 22:25:02 +0900 Subject: [PATCH 3/7] refactor astro-attrs.test.js to unit tests (#15557) * refactor astro-attrs.test.js to unit tests * fix indent * remove the space before the `addAttribute` function * update to match the latest `astro-attrs.test.js` --- packages/astro/test/astro-attrs.test.js | 116 ------- .../fixtures/astro-attrs/astro.config.mjs | 7 - .../test/fixtures/astro-attrs/package.json | 11 - .../src/components/NamespacedSpan.astro | 1 - .../astro-attrs/src/pages/component.astro | 8 - .../astro-attrs/src/pages/index.astro | 51 ---- .../src/pages/namespaced-component.astro | 4 - .../astro-attrs/src/pages/namespaced.astro | 3 - .../astro/test/units/app/astro-attrs.test.js | 283 ++++++++++++++++++ .../react-component}/src/components/Span.jsx | 0 .../react-component/src/pages/index.astro | 3 + .../react/test/react-component.test.js | 6 + pnpm-lock.yaml | 15 - 13 files changed, 292 insertions(+), 216 deletions(-) delete mode 100644 packages/astro/test/astro-attrs.test.js delete mode 100644 packages/astro/test/fixtures/astro-attrs/astro.config.mjs delete mode 100644 packages/astro/test/fixtures/astro-attrs/package.json delete mode 100644 packages/astro/test/fixtures/astro-attrs/src/components/NamespacedSpan.astro delete mode 100644 packages/astro/test/fixtures/astro-attrs/src/pages/component.astro delete mode 100644 packages/astro/test/fixtures/astro-attrs/src/pages/index.astro delete mode 100644 packages/astro/test/fixtures/astro-attrs/src/pages/namespaced-component.astro delete mode 100644 packages/astro/test/fixtures/astro-attrs/src/pages/namespaced.astro create mode 100644 packages/astro/test/units/app/astro-attrs.test.js rename packages/{astro/test/fixtures/astro-attrs => integrations/react/test/fixtures/react-component}/src/components/Span.jsx (100%) diff --git a/packages/astro/test/astro-attrs.test.js b/packages/astro/test/astro-attrs.test.js deleted file mode 100644 index 23d852d37e33..000000000000 --- a/packages/astro/test/astro-attrs.test.js +++ /dev/null @@ -1,116 +0,0 @@ -import assert from 'node:assert/strict'; -import { before, describe, it } from 'node:test'; -import * as cheerio from 'cheerio'; -import { loadFixture } from './test-utils.js'; - -describe('Attributes', async () => { - let fixture; - - before(async () => { - fixture = await loadFixture({ root: './fixtures/astro-attrs/' }); - await fixture.build(); - }); - - it('Passes attributes to elements as expected', async () => { - const html = await fixture.readFile('/index.html'); - const $ = cheerio.load(html); - - const attrs = { - 'download-true': { attribute: 'download', value: '' }, - 'download-false': { attribute: 'download', value: undefined }, - 'download-undefined': { attribute: 'download', value: undefined }, - 'download-string-empty': { attribute: 'download', value: '' }, - 'download-string': { attribute: 'download', value: 'my-document.pdf' }, - 'popover-auto': { attribute: 'popover', value: 'auto' }, - 'popover-true': { attribute: 'popover', value: '' }, - 'popover-false': { attribute: 'popover', value: undefined }, - 'popover-string-empty': { attribute: 'popover', value: '' }, - // Note: cheerio normalizes boolean `hidden` to the string "hidden", - // so we use "hidden" as the expected value instead of "" - 'hidden-true': { attribute: 'hidden', value: 'hidden' }, - 'hidden-false': { attribute: 'hidden', value: undefined }, - 'hidden-string-empty': { attribute: 'hidden', value: 'hidden' }, - 'boolean-attr-true': { attribute: 'allowfullscreen', value: '' }, - 'boolean-attr-false': { attribute: 'allowfullscreen', value: undefined }, - 'boolean-attr-string-truthy': { attribute: 'allowfullscreen', value: '' }, - 'boolean-attr-string-falsy': { attribute: 'allowfullscreen', value: undefined }, - 'boolean-attr-number-truthy': { attribute: 'allowfullscreen', value: '' }, - 'boolean-attr-number-falsy': { attribute: 'allowfullscreen', value: undefined }, - 'data-attr-true': { attribute: 'data-foobar', value: 'true' }, - 'data-attr-false': { attribute: 'data-foobar', value: 'false' }, - 'data-attr-string-truthy': { attribute: 'data-foobar', value: 'foo' }, - 'data-attr-string-falsy': { attribute: 'data-foobar', value: '' }, - 'data-attr-number-truthy': { attribute: 'data-foobar', value: '1' }, - 'data-attr-number-falsy': { attribute: 'data-foobar', value: '0' }, - 'normal-attr-true': { attribute: 'foobar', value: 'true' }, - 'normal-attr-false': { attribute: 'foobar', value: 'false' }, - 'normal-attr-string-truthy': { attribute: 'foobar', value: 'foo' }, - 'normal-attr-string-falsy': { attribute: 'foobar', value: '' }, - 'normal-attr-number-truthy': { attribute: 'foobar', value: '1' }, - 'normal-attr-number-falsy': { attribute: 'foobar', value: '0' }, - null: { attribute: 'attr', value: undefined }, - undefined: { attribute: 'attr', value: undefined }, - 'html-enum': { attribute: 'draggable', value: 'true' }, - 'html-enum-true': { attribute: 'draggable', value: 'true' }, - 'html-enum-false': { attribute: 'draggable', value: 'false' }, - }; - - // cheerio normalizes hidden="until-found" to just hidden, so we check the raw HTML - assert.ok( - html.includes('id="hidden-until-found" hidden="until-found"'), - 'hidden="until-found" should preserve the attribute value', - ); - assert.ok(!/allowfullscreen=/.test(html), 'boolean attributes should not have values'); - assert.ok( - !/id="data-attr-string-falsy"\s+data-foobar=/.test(html), - "data attributes should not have values if it's an empty string", - ); - assert.ok( - !/id="normal-attr-string-falsy"\s+data-foobar=/.test(html), - "normal attributes should not have values if it's an empty string", - ); - - // cheerio will unescape the values, so checking that the url rendered escaped has to be done manually - assert.equal( - html.includes('https://example.com/api/og?title=hello&description=somedescription'), - true, - ); - - // cheerio will unescape the values, so checking that the url rendered unescaped to begin with has to be done manually - assert.equal( - html.includes('cmd: echo "foo" && echo "bar" > /tmp/hello.txt'), - true, - ); - - for (const id of Object.keys(attrs)) { - const { attribute, value } = attrs[id]; - const attr = $(`#${id}`).attr(attribute); - assert.equal(attr, value, `Expected ${attribute} to be ${value} for #${id}`); - } - }); - - it('Passes boolean attributes to components as expected', async () => { - const html = await fixture.readFile('/component/index.html'); - const $ = cheerio.load(html); - - assert.equal($('#true').attr('attr'), 'attr-true'); - assert.equal($('#true').attr('type'), 'boolean'); - assert.equal($('#false').attr('attr'), 'attr-false'); - assert.equal($('#false').attr('type'), 'boolean'); - }); - - it('Passes namespaced attributes as expected', async () => { - const html = await fixture.readFile('/namespaced/index.html'); - const $ = cheerio.load(html); - - assert.equal($('div').attr('xmlns:happy'), 'https://example.com/schemas/happy'); - assert.equal($('img').attr('happy:smile'), 'sweet'); - }); - - it('Passes namespaced attributes to components as expected', async () => { - const html = await fixture.readFile('/namespaced-component/index.html'); - const $ = cheerio.load(html); - - assert.deepEqual($('span').attr('on:click'), '(event) => console.log(event)'); - }); -}); diff --git a/packages/astro/test/fixtures/astro-attrs/astro.config.mjs b/packages/astro/test/fixtures/astro-attrs/astro.config.mjs deleted file mode 100644 index e7ce274c003a..000000000000 --- a/packages/astro/test/fixtures/astro-attrs/astro.config.mjs +++ /dev/null @@ -1,7 +0,0 @@ -import react from '@astrojs/react'; -import { defineConfig } from 'astro/config'; - -// https://astro.build/config -export default defineConfig({ - integrations: [react()], -}); diff --git a/packages/astro/test/fixtures/astro-attrs/package.json b/packages/astro/test/fixtures/astro-attrs/package.json deleted file mode 100644 index d0085726d054..000000000000 --- a/packages/astro/test/fixtures/astro-attrs/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@test/astro-attrs", - "version": "0.0.0", - "private": true, - "dependencies": { - "@astrojs/react": "workspace:*", - "astro": "workspace:*", - "react": "^18.3.1", - "react-dom": "^18.3.1" - } -} diff --git a/packages/astro/test/fixtures/astro-attrs/src/components/NamespacedSpan.astro b/packages/astro/test/fixtures/astro-attrs/src/components/NamespacedSpan.astro deleted file mode 100644 index bfadf035c518..000000000000 --- a/packages/astro/test/fixtures/astro-attrs/src/components/NamespacedSpan.astro +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/astro/test/fixtures/astro-attrs/src/pages/component.astro b/packages/astro/test/fixtures/astro-attrs/src/pages/component.astro deleted file mode 100644 index cfdc636c804e..000000000000 --- a/packages/astro/test/fixtures/astro-attrs/src/pages/component.astro +++ /dev/null @@ -1,8 +0,0 @@ ---- -import Span from '../components/Span.jsx'; ---- - - - - - diff --git a/packages/astro/test/fixtures/astro-attrs/src/pages/index.astro b/packages/astro/test/fixtures/astro-attrs/src/pages/index.astro deleted file mode 100644 index 55503e094d82..000000000000 --- a/packages/astro/test/fixtures/astro-attrs/src/pages/index.astro +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - -