Address response bodies by response id instead of a filesystem path

The body commands took a path from the client, so a token holder could
read any file the process could. They now take a response id and resolve
the location themselves, and the UI never sees a path at all: the desktop
host asks the backend where the file is, and the bridge fetches
/responses/:id/body.

Ephemeral responses (GraphQL introspection) never reach the database, so
resolution falls back to the path send writes them to.
This commit is contained in:
Gregory Schier
2026-08-14 17:02:05 -07:00
parent 99a318224f
commit 448d349af8
18 changed files with 366 additions and 110 deletions
+24 -17
View File
@@ -63,14 +63,13 @@ function detectOsType(): OsType {
}
/**
* A response body path is an opaque handle the backend minted, and the bridge
* writes them as `<data dir>/responses/<response id>`. Taking the last segment
* turns it back into the id the `/responses/{id}/body` route wants, which keeps
* the server from ever being asked for a path chosen by the page.
* The route that serves a response body, keyed by the response id.
*
* The bridge resolves the id against its own database, so this is the only
* thing the page ever has to know about where a body lives.
*/
function responseIdFromBodyPath(path: string): string {
const segments = path.split(/[/\\]/);
return segments[segments.length - 1] ?? path;
function responseBodyRoute(responseId: string): string {
return `/responses/${encodeURIComponent(responseId)}/body`;
}
/**
@@ -221,25 +220,33 @@ export function createBridgePlatform(baseUrl: string, token: string | null): Pla
},
files: {
async readFile(path) {
const res = await connection.fetch(`/responses/${responseIdFromBodyPath(path)}/body`);
readDir: async () => {
throw unsupported("Browsing the filesystem");
},
// App resources are served by the page's own origin, so the path the
// caller resolved is already a URL a tab can load.
url: (path) => path,
basename: async (path) => path.split(/[/\\]/).pop() ?? path,
resolveResource: async (path) => path,
// The route takes the response id and looks the body up itself, so the
// page never names a file — the same reason the desktop asks the backend
// for the path instead of building one.
async readResponseBody(responseId) {
const res = await connection.fetch(responseBodyRoute(responseId));
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(`Failed to read response body (${res.status})`);
}
return new Uint8Array(await res.arrayBuffer());
},
readDir: async () => {
throw unsupported("Browsing the filesystem");
},
// The `<img src>`/`<video src>` equivalent of Tauri's `convertFileSrc`.
// The token rides in the query because the browser makes these requests
// itself and the page cannot add a header to them.
url: (path) => connection.url(`/responses/${responseIdFromBodyPath(path)}/body`),
basename: async (path) => path.split(/[/\\]/).pop() ?? path,
resolveResource: async (path) => path,
responseBodyUrl: async (responseId) => connection.url(responseBodyRoute(responseId)),
},
rpc: <T,>(cmd: string, payload?: RpcPayload): Promise<T> => {
+21 -1
View File
@@ -104,6 +104,17 @@ async function rpc<T>(cmd: string, payload?: RpcPayload): Promise<T> {
}
}
/**
* Where the backend put a response's body, or null if it has none.
*
* The app holds response ids and nothing else; a path exists for exactly as
* long as it takes this host to open the file, which is the one thing a desktop
* host can do that a tab cannot.
*/
function responseBodyPath(responseId: string): Promise<string | null> {
return rpc<string | null>("cmd_http_response_body_path", { responseId });
}
export function createTauriPlatform(): Platform {
const window = createWindow();
@@ -124,11 +135,20 @@ export function createTauriPlatform(): Platform {
},
files: {
readFile: (path) => readFile(path),
readDir: (path) => readDir(path),
url: (path) => convertFileSrc(path),
basename: (path) => basename(path),
resolveResource: (path) => resolveResource(path),
async readResponseBody(responseId) {
const path = await responseBodyPath(responseId);
return path == null ? null : readFile(path);
},
async responseBodyUrl(responseId) {
const path = await responseBodyPath(responseId);
return path == null ? null : convertFileSrc(path);
},
},
rpc,
+19 -2
View File
@@ -135,11 +135,15 @@ export interface PlatformDialog {
* File-ish operations, keyed by paths the backend handed us.
*
* A path here is an opaque handle, not something to parse or construct: the UI
* only ever passes one back to `readFile` or `url`. A host without a filesystem
* only ever passes one back to `url` or `readDir`. A host without a filesystem
* can mint handles of its own (a blob id, a URL) and stay compatible.
*
* Response bodies are addressed by response id instead, because they are the
* one thing every host stores somewhere different. The desktop reads the file
* the engine wrote; the bridge fetches it over HTTP. Neither is ever handed a
* location the page chose.
*/
export interface PlatformFiles {
readFile(path: string): Promise<Uint8Array<ArrayBuffer>>;
readDir(path: string): Promise<DirEntry[]>;
/** A URL the page can load a file from, for `<img>`, `<video>`, and friends. */
@@ -149,6 +153,19 @@ export interface PlatformFiles {
/** Resolve a path bundled with the app itself, rather than one from the backend. */
resolveResource(path: string): Promise<string>;
/** The bytes of a stored response body, or null if the response has none. */
readResponseBody(responseId: string): Promise<Uint8Array<ArrayBuffer> | null>;
/**
* A URL the media viewers can point an element at, or null if the response
* has no stored body.
*
* Asynchronous because the host may have to ask the backend where the body
* is: only the host is allowed to know that, and only the moment before it
* opens it.
*/
responseBodyUrl(responseId: string): Promise<string | null>;
}
export interface Platform {