fix(export): let the keepsake download through X-Frame-Options on iOS
The keepsake download navigates a hidden, same-origin iframe (deliberately: a top-level navigation to a 404/429 would unload the PWA). Caddy stamped a site-wide `X-Frame-Options: DENY` that also covered the proxied `/api/*`. Blink hands a `Content-Disposition: attachment` response to the download manager at the network layer, so Chromium never noticed. WebKit enforces XFO on the frame navigation first and aborts the load — so on iOS Safari, the app's primary platform, tapping Download did nothing at all, silently. Carve the two export endpoints out to SAMEORIGIN, which still blocks cross-origin framing. Implemented as two disjoint matchers rather than an override: Caddy applies the FIRST `header` directive outermost, so it wins on write and a later, more specific `header` is silently ignored (verified against the running test stack). Also close the test gap that let this ship: - `06-export` ran on chromium-desktop only; add it to `webkit-iphone`, the only engine that enforces XFO on the download frame. - No test in the suite ever clicked a download button — every archive assertion used Node `fetch`, which has no frame and no XFO enforcement. Add a spec that clicks it and awaits a real `download` event. Verified falsifiable: with the blanket DENY reinstated it fails and reports the WebKit refusal as the cause. - Fix `ExportPage`'s card-scoped locators, which matched nothing: the cards carry `class="card p-5"` (a Tailwind `@apply` component class), never the `rounded-xl` the page object looked for. This had left the "shows enabled download buttons" test red on main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
103
e2e/specs/06-export/download-iframe.spec.ts
Normal file
103
e2e/specs/06-export/download-iframe.spec.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Regression guard — the keepsake download must actually download, in WebKit.
|
||||
*
|
||||
* The bug this exists to catch: `/export` streams the archive by pointing a HIDDEN,
|
||||
* SAME-ORIGIN iframe at `/api/v1/export/zip` (deliberately — a top-level navigation to a
|
||||
* 404/429 would unload the PWA). Caddy stamped a site-wide `X-Frame-Options: DENY` that
|
||||
* also covered `/api/*`. Blink hands a `Content-Disposition: attachment` response to the
|
||||
* download manager at the network layer, so Chromium never noticed; WebKit enforces XFO on
|
||||
* the frame navigation FIRST and aborts the load. Result: on iOS Safari — the app's primary
|
||||
* platform — tapping Download did nothing, silently, with no error anywhere.
|
||||
*
|
||||
* Why the old suite was structurally blind to it:
|
||||
* - `06-export` ran on `chromium-desktop` only (webkit-iphone's testMatch excluded it),
|
||||
* - and no test in the entire suite ever CLICKED a download button; every archive
|
||||
* assertion used Node `fetch`, which has no frame and therefore no XFO enforcement.
|
||||
*
|
||||
* So this spec must keep both properties to be worth anything: a real click, in WebKit.
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { ExportPage } from '../../page-objects';
|
||||
import { BASE } from '../../helpers/env';
|
||||
|
||||
const SLUG = 'e2e-test-event';
|
||||
|
||||
function post(path: string, jwt: string) {
|
||||
return fetch(BASE + path, { method: 'POST', headers: { Authorization: `Bearer ${jwt}` } });
|
||||
}
|
||||
|
||||
/** The real export job runs image processing; give it head-room over the tiny fixtures. */
|
||||
async function releaseAndWait(jwt: string) {
|
||||
expect((await post('/api/v1/host/gallery/release', jwt)).status).toBe(204);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await fetch(BASE + '/api/v1/export/status', {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
const s = await res.json();
|
||||
return s.released === true && s.zip?.status === 'done';
|
||||
},
|
||||
{ timeout: 60_000, intervals: [500] }
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test.describe('Export — the download actually fires in the browser', () => {
|
||||
test.slow();
|
||||
|
||||
test('clicking Download triggers a real download event', async ({ page, host, signIn, db }) => {
|
||||
await db.setExportReleased(SLUG, false);
|
||||
await releaseAndWait(host.jwt);
|
||||
|
||||
await signIn(page, host);
|
||||
const exportPage = new ExportPage(page);
|
||||
await exportPage.goto();
|
||||
await expect(exportPage.zipDownloadButton).toBeEnabled({ timeout: 10_000 });
|
||||
|
||||
// Capture the frame-level refusal that XFO produces, so a failure reports the CAUSE
|
||||
// rather than just a timeout. WebKit logs "Refused to display ... in a frame because it
|
||||
// set 'X-Frame-Options'"; Chromium logs nothing here, which is the whole problem.
|
||||
const refusals: string[] = [];
|
||||
page.on('console', (m) => {
|
||||
if (/X-Frame-Options|Refused to display/i.test(m.text())) refusals.push(m.text());
|
||||
});
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', { timeout: 30_000 });
|
||||
await exportPage.zipDownloadButton.click();
|
||||
|
||||
const download = await downloadPromise.catch((err) => {
|
||||
throw new Error(
|
||||
`No download event fired after clicking the ZIP button.` +
|
||||
(refusals.length
|
||||
? ` The browser refused the iframe navigation: ${refusals.join(' | ')}`
|
||||
: ' No X-Frame-Options refusal was logged; check the ticket/readiness path.') +
|
||||
`\n${err}`
|
||||
);
|
||||
});
|
||||
|
||||
expect(download.suggestedFilename()).toMatch(/\.zip$/i);
|
||||
// The stream must produce real bytes, not a zero-length placeholder.
|
||||
const path = await download.path();
|
||||
expect(path).toBeTruthy();
|
||||
expect(refusals, 'no frame should have been refused').toEqual([]);
|
||||
});
|
||||
|
||||
test('the export endpoints are framable same-origin; everything else stays DENY', async () => {
|
||||
// Locks the Caddy carve-out itself, independently of any browser. Cheap, and it fails
|
||||
// loudly at the exact layer that regressed if someone reinstates a blanket DENY.
|
||||
for (const path of ['/api/v1/export/zip', '/api/v1/export/html']) {
|
||||
const res = await fetch(BASE + path);
|
||||
expect(res.headers.get('x-frame-options')?.toUpperCase(), `${path} must be framable`).toBe(
|
||||
'SAMEORIGIN'
|
||||
);
|
||||
}
|
||||
|
||||
for (const path of ['/', '/api/v1/feed', '/api/v1/event']) {
|
||||
const res = await fetch(BASE + path);
|
||||
expect(res.headers.get('x-frame-options')?.toUpperCase(), `${path} must stay DENY`).toBe(
|
||||
'DENY'
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user