Skip to content

Out-of-order httpHeader cleanup removes surviving cookies and headers #2984

Description

@brenelz

Describe the bug

httpHeader() cleanup restores a whole write-time snapshot of the header. That is correct only when scopes dispose in reverse write order. With independently recovering sibling SSR scopes, an earlier writer can dispose while a later writer remains live; restoring the earlier snapshot then deletes the later scope's surviving contribution.

For Set-Cookie, this can silently drop authentication or session cookies from the outgoing response. Ordinary headers and httpStatus() have the same ordering problem.

At a2057304:

  • httpHeader() snapshots and restores the complete previous header value/cookie list:
    export function httpHeader(name: string, value: string, options?: { append?: boolean }): void {
    const event = getRequestEvent() as (RequestEvent & { response?: ResponseStub }) | undefined;
    const response = event && event.response;
    if (response && !response.committed) {
    const headers = response.headers;
    // Entry-exact path for set-cookie: `get()` comma-joins multiple
    // entries, and restoring that join through `set()` would collapse them
    // into one corrupt header (commas are legal inside a single cookie's
    // `Expires`), so snapshot the entry list and rebuild it on retraction.
    const setCookie = name.toLowerCase() === "set-cookie";
    const prevCookies = setCookie ? headers.getSetCookie() : undefined;
    const prev = setCookie ? null : headers.get(name);
    if (options && options.append) headers.append(name, value);
    else headers.set(name, value);
    onCleanup(() => {
    if (response.committed) return;
    if (setCookie) {
    headers.delete(name);
    for (const cookie of prevCookies!) headers.append(name, cookie);
    } else if (prev === null) headers.delete(name);
    else headers.set(name, prev);
    });
  • httpStatus() uses the same write-time snapshot approach:
    export function httpStatus(code: number, text?: string): void {
    // `response` is an integration-augmented field (see core's ResponseStub);
    // read it structurally so the primitives work against the bare contract.
    const event = getRequestEvent() as (RequestEvent & { response?: ResponseStub }) | undefined;
    const response = event && event.response;
    if (response && !response.committed) {
    const prevStatus = response.status;
    const prevStatusText = response.statusText;
    response.status = code;
    response.statusText = text;
    onCleanup(() => {
    if (response.committed) return;
    response.status = prevStatus;
    response.statusText = prevStatusText;
    });

Reproduction

import { AsyncLocalStorage } from "node:async_hooks";
import { httpHeader } from "@solidjs/web";
import { createRoot } from "solid-js";

const RequestContext = Symbol.for("solid.RequestContext");
const storage = new AsyncLocalStorage<any>();
(globalThis as any)[RequestContext] = storage;

const event = {
  request: new Request("http://localhost/"),
  locals: {},
  response: {
    status: 200,
    statusText: undefined,
    headers: new Headers(),
    committed: false
  }
};

storage.run(event, () => {
  createRoot(disposeAll => {
    let disposeEarly!: () => void;

    // Sibling A writes first.
    createRoot(dispose => {
      disposeEarly = dispose;
      httpHeader("set-cookie", "early=1; Path=/", { append: true });
    });

    // Sibling B writes later and remains live.
    createRoot(() => {
      httpHeader("set-cookie", "session=abc; Path=/; HttpOnly", {
        append: true
      });
    });

    console.log(event.response.headers.getSetCookie());
    // ["early=1; Path=/", "session=abc; Path=/; HttpOnly"]

    // A recovers/disposes before B.
    disposeEarly();

    console.log(event.response.headers.getSetCookie());
    // Actual: []
    // Expected: ["session=abc; Path=/; HttpOnly"]

    disposeAll();
  });
});

The same sequence with:

httpHeader("x-shared", "early");
httpHeader("x-shared", "late", { append: true });

changes "early, late" to null when the early scope disposes, rather than preserving "late".

Expected behavior

Disposing one scope should retract only that scope's declaration. Contributions from still-live scopes must remain in the outgoing response regardless of disposal order.

This likely requires tracking individual declarations/contributions and recomputing the effective status/header on cleanup instead of restoring a stale whole-field snapshot.

Platform

  • OS: macOS and Linux
  • Runtime: Node.js SSR
  • Version: @solidjs/web@2.0.0-beta.32

Additional context

The current tests cover nested/LIFO restoration, where snapshot restore works. The failing shape requires sibling declarations to dispose out of write order, which occurs when an earlier asynchronous boundary recovers while a later sibling remains active.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions