Files
airship/packages/server/src/proxy.test.ts
T
Nayan 3d88cf55c5 fix(server): strip framing headers from every surface-destined response
A dev server that sends X-Frame-Options or a frame-ancestors CSP (Shopify's
`shopify theme dev` does) told the browser not to render the app inside the
canvas frame airship itself created — the shell painted, the frame showed a
broken-document icon, and nothing explained why.

PR #16 diagnosed this and stripped the headers on the injection path. This
goes the rest of the way: responses the proxy cannot inject into (gzip
upstream, non-UTF-8 charset) took the passthrough branch, which forwarded
every upstream header verbatim, so the frame stayed blank exactly when the
overlay was already degraded. resolveMode is now computed before the
upstream round trip so the passthrough branch knows a response is
frame-destined, and one exported pure filter serves both branches.

The CSP is dropped wholesale, not edited: the policy governs the injected
editor itself (the inline config script, injected styles, the control
socket), so surgically removing frame-ancestors would still break the
overlay under a strict script-src. --keep-csp / AIRSHIP_KEEP_CSP opts back
in for someone iterating on their own policy; X-Frame-Options is dropped
regardless, since inside the editor it can only blank the frame.

Diagnosis and the shopify theme dev reproduction: jormon (#16).
2026-08-16 13:05:37 +05:30

203 lines
6.9 KiB
TypeScript

import type http from "node:http";
import { AIRSHIP_SURFACE_COOKIE } from "@airship/protocol";
import { describe, expect, it } from "vitest";
import { filterProxyHeaders, resolveMode } from "./proxy";
/** Just enough of an IncomingMessage for `resolveMode`. */
function req(opts: {
cookie?: string;
dest?: string;
url?: string;
}): http.IncomingMessage {
return {
headers: {
...(opts.cookie ? { cookie: opts.cookie } : {}),
...(opts.dest ? { "sec-fetch-dest": opts.dest } : {}),
},
url: opts.url ?? "/",
} as unknown as http.IncomingMessage;
}
const cookie = (surface: string): string =>
`${AIRSHIP_SURFACE_COOKIE}=${surface}`;
describe("resolveMode", () => {
describe("an explicit ?__airship= always wins", () => {
for (const mode of ["shell", "inline", "frame"] as const) {
it(`serves ${mode} regardless of default, dest or cookie`, () => {
const request = req({
cookie: cookie("canvas"),
dest: "empty",
url: `/?__airship=${mode}`,
});
expect(resolveMode(request, "shell")).toBe(mode);
expect(resolveMode(request, "inline")).toBe(mode);
});
}
it("ignores a value that is not a mode", () => {
expect(resolveMode(req({ url: "/?__airship=canvas" }), "shell")).toBe(
"shell"
);
});
});
describe("a document navigation takes the default", () => {
for (const dest of [undefined, "document"]) {
it(`serves the canvas default for dest=${dest ?? "(absent)"}`, () => {
expect(resolveMode(req({ dest }), "shell")).toBe("shell");
});
it(`serves the inline default for dest=${dest ?? "(absent)"}`, () => {
expect(resolveMode(req({ dest }), "inline")).toBe("inline");
});
}
});
describe("the surface cookie outranks the launch default", () => {
it("makes a canvas launch serve inline", () => {
expect(resolveMode(req({ cookie: cookie("inline") }), "shell")).toBe(
"inline"
);
});
it("makes an inline launch serve the canvas", () => {
expect(resolveMode(req({ cookie: cookie("canvas") }), "inline")).toBe(
"shell"
);
});
it("is found among other cookies", () => {
const request = req({
cookie: `theme=dark; ${cookie("inline")}; sid=abc`,
});
expect(resolveMode(request, "shell")).toBe("inline");
});
it("falls back to the default when the value is not a surface", () => {
const request = req({ cookie: `${AIRSHIP_SURFACE_COOKIE}=shell` });
expect(resolveMode(request, "shell")).toBe("shell");
});
it("never applies to a frame", () => {
const request = req({ cookie: cookie("inline"), dest: "iframe" });
expect(resolveMode(request, "shell")).toBe("frame");
});
it("never applies to an HTML partial", () => {
const request = req({ cookie: cookie("canvas"), dest: "empty" });
expect(resolveMode(request, "inline")).toBe("passthrough");
});
});
describe("embedded destinations", () => {
for (const dest of ["iframe", "embed", "object"]) {
it(`promotes ${dest} to a frame under the canvas`, () => {
expect(resolveMode(req({ dest }), "shell")).toBe("frame");
});
// The regression this guards: under inline there is no shell driving
// frames, so an iframe in the document is the app's own — a video embed,
// a payment form — and installing a frame agent in it would be injecting
// the editor into a third party for no purpose.
it(`leaves ${dest} untouched under inline`, () => {
expect(resolveMode(req({ dest }), "inline")).toBe("passthrough");
});
}
});
describe("everything else passes through", () => {
for (const dest of ["empty", "script", "style", "image"]) {
it(`passes through dest=${dest}`, () => {
expect(resolveMode(req({ dest }), "shell")).toBe("passthrough");
expect(resolveMode(req({ dest }), "inline")).toBe("passthrough");
});
}
});
});
describe("filterProxyHeaders", () => {
// As a dev server would send them: original casing, framing headers set.
const upstream = {
"Content-Security-Policy": "frame-ancestors 'none'; script-src 'self'",
"Content-Security-Policy-Report-Only": "frame-ancestors 'none'",
"cache-control": "no-cache",
"content-encoding": "gzip",
"content-length": "1234",
"content-type": "text/html",
"X-Frame-Options": "DENY",
} as http.IncomingHttpHeaders;
it("strips framing headers from a frame-destined passthrough", () => {
const out = filterProxyHeaders(upstream, {
forSurface: true,
injecting: false,
keepCsp: false,
});
expect(out["X-Frame-Options"]).toBeUndefined();
expect(out["Content-Security-Policy"]).toBeUndefined();
expect(out["Content-Security-Policy-Report-Only"]).toBeUndefined();
// Passthrough bodies are untouched, so length and encoding must survive.
expect(out["content-length"]).toBe("1234");
expect(out["content-encoding"]).toBe("gzip");
expect(out["cache-control"]).toBe("no-cache");
});
it("keeps the CSP pair under keepCsp, but never X-Frame-Options", () => {
const out = filterProxyHeaders(upstream, {
forSurface: true,
injecting: false,
keepCsp: true,
});
expect(out["Content-Security-Policy"]).toBe(
"frame-ancestors 'none'; script-src 'self'"
);
expect(out["Content-Security-Policy-Report-Only"]).toBe(
"frame-ancestors 'none'"
);
expect(out["X-Frame-Options"]).toBeUndefined();
});
it("leaves a subresource passthrough completely untouched", () => {
const out = filterProxyHeaders(upstream, {
forSurface: false,
injecting: false,
keepCsp: false,
});
expect(out).toEqual(upstream);
});
it("strips framing headers and hop-by-hop when injecting", () => {
const out = filterProxyHeaders(
{ ...upstream, connection: "keep-alive", "transfer-encoding": "chunked" },
{ forSurface: true, injecting: true, keepCsp: false }
);
expect(out["X-Frame-Options"]).toBeUndefined();
expect(out["Content-Security-Policy"]).toBeUndefined();
// The body is rewritten, so length and encoding are dropped for recompute.
expect(out["content-length"]).toBeUndefined();
expect(out["content-encoding"]).toBeUndefined();
expect(out.connection).toBeUndefined();
expect(out["transfer-encoding"]).toBeUndefined();
expect(out["content-type"]).toBe("text/html");
});
it("strips framing headers when injecting inline, too", () => {
const out = filterProxyHeaders(upstream, {
forSurface: false,
injecting: true,
keepCsp: false,
});
expect(out["X-Frame-Options"]).toBeUndefined();
expect(out["Content-Security-Policy"]).toBeUndefined();
});
it("preserves multi-valued headers as arrays", () => {
const out = filterProxyHeaders(
{ "set-cookie": ["a=1", "b=2"] },
{ forSurface: true, injecting: false, keepCsp: false }
);
expect(out["set-cookie"]).toEqual(["a=1", "b=2"]);
});
});