Skip to content

Commit 7337fc2

Browse files
authored
Wrap IndexedDB-restored files before upload so Safari sends their bytes (#7889)
On Safari and DuckDuckGo 26.5+, a file read back from IndexedDB uploads as an empty body (Content-Length 0). The server rejects the request before any controller runs, so a policy run on that file never starts and no failure is recorded. Chrome is unaffected. The file comes out of IndexedDB with a disk path attached. WebKit uploads any file with a path by reading it from disk, and since 26.5 the process doing the upload isn't allowed to read that path. It gets nothing back and sends empty bytes instead of an error. **Fix:** wrap the file in a fresh `File` before putting it in the form. The wrapper has no path, so every browser uploads it through the blob route. No bytes are copied. This is a patch while we wait for the upstream fix: https://bugs.webkit.org/show_bug.cgi?id=319985. It stays correct after that fix lands, whatever shape it takes, because the wrapper never depends on the path route. Verified in Safari 26.6.2 with a probe page: the raw IndexedDB file uploads 0 bytes, the wrapped file uploads the full body, including after the stored record is rewritten or deleted and with several uploads in parallel. Only the policy run path is covered here. The other upload sites share the exposure and are tracked separately.
1 parent e2a0a58 commit 7337fc2

4 files changed

Lines changed: 82 additions & 1 deletion

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from "vitest";
2+
import { uploadableFile } from "@app/utils/uploadableFile";
3+
4+
/** jsdom's Blob has no text(); FileReader is the one byte read it implements. */
5+
const textOf = (file: File) =>
6+
new Promise<string>((resolve, reject) => {
7+
const reader = new FileReader();
8+
reader.onload = () => resolve(reader.result as string);
9+
reader.onerror = () => reject(reader.error);
10+
reader.readAsText(file);
11+
});
12+
13+
describe("uploadableFile", () => {
14+
const source = new File(["%PDF-1.7 body"], "report.pdf", {
15+
type: "application/pdf",
16+
lastModified: 1_700_000_000_000,
17+
});
18+
19+
it("returns a distinct File object", () => {
20+
expect(uploadableFile(source)).not.toBe(source);
21+
});
22+
23+
it("keeps the identity fields the server and the run record read", () => {
24+
const wrapped = uploadableFile(source);
25+
expect(wrapped.name).toBe(source.name);
26+
expect(wrapped.type).toBe(source.type);
27+
expect(wrapped.lastModified).toBe(source.lastModified);
28+
expect(wrapped.size).toBe(source.size);
29+
});
30+
31+
it("carries the same bytes", async () => {
32+
expect(await textOf(uploadableFile(source))).toBe(await textOf(source));
33+
});
34+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* The File to put in a multipart body when the caller's File may have come back
3+
* from IndexedDB.
4+
*
5+
* WebKit stamps a File restored from IndexedDB with a disk path into its own
6+
* storage folder, and its form serialiser reads any File with a path from disk.
7+
* Since Safari 26.5 the process doing the upload may not read that folder, so
8+
* the request goes out with Content-Length 0 and no error, while the same File
9+
* reads its bytes fine in JavaScript (https://bugs.webkit.org/show_bug.cgi?id=319985).
10+
* The server sees a multipart request with no boundary and rejects it before
11+
* any controller runs.
12+
*
13+
* A File built over another File has no path, so every engine serialises it
14+
* through the blob route, which is also the route WebKit takes for a File it
15+
* never gave a path. That makes this wrapper correct on every browser today and
16+
* after the upstream fix lands, whichever shape that fix takes. It references
17+
* the bytes rather than copying them, so it is safe for arbitrarily large files,
18+
* and it stays uploadable after the record it came from is rewritten or deleted.
19+
*/
20+
export function uploadableFile(file: File): File {
21+
return new File([file], file.name, {
22+
type: file.type,
23+
lastModified: file.lastModified,
24+
});
25+
}

frontend/editor/src/proprietary/services/policyApi.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ function sentForm(): FormData {
1818
return post.mock.calls.at(-1)?.[1] as FormData;
1919
}
2020

21+
/** jsdom's Blob has no text(); FileReader is the one byte read it implements. */
22+
const textOf = (file: File) =>
23+
new Promise<string>((resolve, reject) => {
24+
const reader = new FileReader();
25+
reader.onload = () => resolve(reader.result as string);
26+
reader.onerror = () => reject(reader.error);
27+
reader.readAsText(file);
28+
});
29+
2130
const document = () =>
2231
new File(["%PDF-1.7"], "quarterly-report.pdf", { type: "application/pdf" });
2332

@@ -45,4 +54,15 @@ describe("runStoredPolicy", () => {
4554

4655
expect(sentForm().get("fileId")).not.toContain("quarterly-report");
4756
});
57+
58+
it("uploads a fresh File over the caller's bytes, never the caller's File object", async () => {
59+
// A File restored from IndexedDB serialises as an empty body in WebKit; a wrapper does not.
60+
const source = document();
61+
await runStoredPolicy("policy-1", [source], "editor-file-1");
62+
63+
const sent = sentForm().get("fileInput") as File;
64+
expect(sent).not.toBe(source);
65+
expect(sent.name).toBe(source.name);
66+
expect(await textOf(sent)).toBe(await textOf(source));
67+
});
4868
});

frontend/editor/src/proprietary/services/policyApi.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import apiClient from "@app/services/apiClient";
9+
import { uploadableFile } from "@app/utils/uploadableFile";
910
import { getPolicyOutputBaseUrl } from "@app/services/policyOutputBaseUrl";
1011
import type {
1112
BackendPolicy,
@@ -37,7 +38,8 @@ export async function runStoredPolicy(
3738
fileId?: string,
3839
): Promise<string> {
3940
const form = new FormData();
40-
for (const file of files) form.append("fileInput", file);
41+
// Wrapped: WebKit uploads a File restored from IndexedDB as an empty body.
42+
for (const file of files) form.append("fileInput", uploadableFile(file));
4143
if (fileId) form.append("fileId", fileId);
4244
// Don't set Content-Type: the HTTP client must generate multipart/form-data
4345
// WITH its boundary from the FormData body. A manual boundary-less header makes

0 commit comments

Comments
 (0)