From 6e627a3341d268eee15345b7db4ee9a3c4c4f882 Mon Sep 17 00:00:00 2001 From: Avi Date: Fri, 21 Aug 2026 13:48:34 -0500 Subject: [PATCH] Replace upload file paths with single-use pick tokens --- frontend/electron/main.ts | 66 +++++++++++++++++++------- frontend/src/lib/api.ts | 2 +- frontend/src/lib/types.ts | 6 +-- frontend/src/screens/ComposeScreen.tsx | 2 +- frontend/src/state/AppProvider.tsx | 4 +- frontend/src/test/fakeBackend.ts | 9 ++-- 6 files changed, 62 insertions(+), 27 deletions(-) diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index cc579da..92ee9cb 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -1,5 +1,6 @@ import { app, BrowserWindow, clipboard, dialog, ipcMain, protocol } from 'electron'; import { spawn, type ChildProcess } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { createInterface } from 'node:readline'; import * as path from 'node:path'; @@ -155,6 +156,28 @@ function isAllowedMethod(method: unknown): method is string { const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif']; +/** A file chosen through the native dialog, known only to the main process. */ +interface PickedFile { + path: string; + name: string; + mime: string; +} + +/** + * One-time tokens handed to the renderer in place of filesystem paths. + * `upload_image` accepts only these, so a compromised page can never point an + * upload at an arbitrary local file — only at files the user explicitly picked, + * and each exactly once. + */ +const pickedTokens = new Map(); + +/** Mint a single-use upload token for a freshly picked file. */ +function issueToken(file: PickedFile): string { + const token = randomBytes(16).toString('hex'); + pickedTokens.set(token, file); + return token; +} + /** How long to wait for a link-preview page before giving up. */ const LINK_PREVIEW_TIMEOUT_MS = 10_000; /** Cap on how much HTML we parse for meta tags. */ @@ -179,8 +202,8 @@ function mimeForPath(filePath: string): string { } } -/** Show an open dialog and return the chosen image files (empty when cancelled). */ -async function pickImage(): Promise<{ path: string; name: string; mime: string }[]> { +/** Show an open dialog and return one-time tokens for the chosen images. */ +async function pickImage(): Promise<{ token: string; name: string; mime: string }[]> { const result = await dialog.showOpenDialog({ title: 'Attach image(s)', filters: [{ name: 'Images', extensions: IMAGE_EXTENSIONS }], @@ -189,18 +212,21 @@ async function pickImage(): Promise<{ path: string; name: string; mime: string } if (result.canceled) { return []; } - return result.filePaths.map((filePath) => ({ - path: filePath, - name: path.basename(filePath), - mime: mimeForPath(filePath), - })); + return result.filePaths.map((filePath) => { + const file: PickedFile = { + path: filePath, + name: path.basename(filePath), + mime: mimeForPath(filePath), + }; + return { token: issueToken(file), name: file.name, mime: file.mime }; + }); } -/** Upload one image file to nostr.build and return the public URL + MIME type. */ -async function uploadImage(filePath: string): Promise<{ url: string; mime: string }> { +/** Upload a picked image to nostr.build and return the public URL + MIME type. */ +async function uploadImage(file: PickedFile): Promise<{ url: string; mime: string }> { const uploadUrl = 'https://nostr.build/api/v2/upload/files'; - const data = readFileSync(filePath); - const mime = mimeForPath(filePath); + const data = readFileSync(file.path); + const mime = file.mime; // nostr.build requires a NIP-98 auth token signed with the active profile's key. const authEnvelope = (await backendRequest('upload_auth', { @@ -212,7 +238,11 @@ async function uploadImage(filePath: string): Promise<{ url: string; mime: strin } const form = new FormData(); - form.append('fileToUpload', new Blob([data], { type: mime }), path.basename(filePath) || 'image'); + form.append( + 'fileToUpload', + new Blob([data], { type: mime }), + path.basename(file.name) || 'image', + ); const response = await fetch(uploadUrl, { method: 'POST', headers: { authorization: authEnvelope.data.authorization }, @@ -407,17 +437,19 @@ app.whenReady().then(() => { return { status: 'ok', data: url ? await fetchLinkPreview(url) : null }; } if (payload.method === 'upload_image') { - const filePath = String(params.path ?? ''); - if (!filePath) { + const token = typeof params.token === 'string' ? params.token : ''; + const file = token ? pickedTokens.get(token) : undefined; + pickedTokens.delete(token); + if (!file) { return { status: 'error', - code: 'bad_request', - message: 'No image file given.', + code: 'unknown_token', + message: 'That image selection has expired. Please attach it again.', details: null, }; } try { - return { status: 'ok', data: await uploadImage(filePath) }; + return { status: 'ok', data: await uploadImage(file) }; } catch (error) { return { status: 'error', diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index af54501..c11e5af 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -75,7 +75,7 @@ export const api = { removeVaultPassword: (password: string) => call('remove_vault_password', { password }), revealSecretKey: (npub: string) => call('reveal_secret_key', { npub }), pickImages: () => call('pick_image'), - uploadImage: (path: string) => call('upload_image', { path }), + uploadImage: (token: string) => call('upload_image', { token }), linkPreview: (url: string) => call('link_preview', { url }), signerConnect: (uri: string) => call('signer_connect', { uri }), signerDisconnect: () => call('signer_disconnect'), diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 89ff526..b239b8a 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -105,10 +105,10 @@ export interface RevealedKey { nsec: string; } -/** An image file selected via the native file dialog. */ +/** An image selected via the native file dialog, referenced by upload token. */ export interface PickedImage { - /** Absolute path on disk. */ - path: string; + /** One-time token that authorises exactly one `upload_image` call. */ + token: string; /** File name for display. */ name: string; /** Best-effort MIME type from the file extension. */ diff --git a/frontend/src/screens/ComposeScreen.tsx b/frontend/src/screens/ComposeScreen.tsx index ceae4a4..63d3914 100644 --- a/frontend/src/screens/ComposeScreen.tsx +++ b/frontend/src/screens/ComposeScreen.tsx @@ -105,7 +105,7 @@ export function ComposeScreen() { const picked = await pickImages(); const uploaded: Attachment[] = []; for (const image of picked) { - const result = await uploadImage(image.path); + const result = await uploadImage(image.token); uploaded.push({ url: result.url, mime: result.mime, name: image.name }); } setAttachments((prev) => [...prev, ...uploaded]); diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index 7be9de1..fb28f3a 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -56,7 +56,7 @@ interface AppContextValue { removeVaultPassword: (password: string) => Promise; revealSecretKey: (npub: string) => Promise; pickImages: () => Promise; - uploadImage: (path: string) => Promise; + uploadImage: (token: string) => Promise; linkPreview: (url: string) => Promise; signerConnect: (uri: string) => Promise; signerDisconnect: () => Promise; @@ -177,7 +177,7 @@ export function AppProvider({ children }: { children: ReactNode }) { ); const revealSecretKey = useCallback((npub: string) => api.revealSecretKey(npub), []); const pickImages = useCallback(() => api.pickImages(), []); - const uploadImage = useCallback((path: string) => api.uploadImage(path), []); + const uploadImage = useCallback((token: string) => api.uploadImage(token), []); const linkPreview = useCallback((url: string) => api.linkPreview(url), []); const signerConnect = useCallback((uri: string) => api.signerConnect(uri), []); const signerDisconnect = useCallback(() => api.signerDisconnect(), []); diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts index 06f441d..314c1e4 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -32,8 +32,8 @@ export interface FakeBackend { nextErrors: Record; /** Every request dispatched through `api.request`, in order. */ requests: { method: string; params: Record }[]; - /** Files returned by `pick_image`. */ - pickedImages: { path: string; name: string; mime: string }[]; + /** Files returned by `pick_image` (token, name, mime — never paths). */ + pickedImages: { token: string; name: string; mime: string }[]; /** URLs returned by `upload_image`, one per call. */ uploadUrls: string[]; /** Current NIP-46 signer status. */ @@ -92,7 +92,7 @@ export function createFakeBackend(initial?: AppState): FakeBackend { relayErrors: new Set(), nextErrors: {}, requests: [], - pickedImages: [{ path: '/tmp/picked.png', name: 'picked.png', mime: 'image/png' }], + pickedImages: [{ token: 'fake-pick-token', name: 'picked.png', mime: 'image/png' }], uploadUrls: ['https://cdn.nostr.build/i/uploaded.png'], signer: makeSignerStatus(), setSigner(next) { @@ -196,6 +196,9 @@ export function createFakeBackend(initial?: AppState): FakeBackend { return [...backend.pickedImages]; case 'upload_image': { + if (typeof params.token !== 'string' || params.token === '') { + throw Object.assign(new Error('No upload token given.'), { code: 'unknown_token' }); + } const index = backend.requests.filter((r) => r.method === 'upload_image').length - 1; const url = backend.uploadUrls[index] ?? backend.uploadUrls[backend.uploadUrls.length - 1] ?? '';