diff --git a/src/download/streaming.test.ts b/src/download/streaming.test.ts index 1464ab0..a7f966f 100644 --- a/src/download/streaming.test.ts +++ b/src/download/streaming.test.ts @@ -66,7 +66,7 @@ describe("probeAndFetchFirstChunk", () => { }), ); - const p = await probeAndFetchFirstChunk("https://x/ep.mp3", 4); + const p = await probeAndFetchFirstChunk("https://example.com/ep.mp3", 4); expect(p.supportsRange).toBe(true); expect(p.totalSize).toBe(10); @@ -86,7 +86,7 @@ describe("probeAndFetchFirstChunk", () => { }), ); - const p = await probeAndFetchFirstChunk("https://x/ep.mp3", 4); + const p = await probeAndFetchFirstChunk("https://example.com/ep.mp3", 4); expect(p.supportsRange).toBe(true); // Must NOT adopt the 4-byte partial Content-Length as the total, or the @@ -102,7 +102,7 @@ describe("probeAndFetchFirstChunk", () => { }), ); - const p = await probeAndFetchFirstChunk("https://x/ep.mp3", 4); + const p = await probeAndFetchFirstChunk("https://example.com/ep.mp3", 4); expect(p.supportsRange).toBe(false); expect(p.totalSize).toBe(5); @@ -110,7 +110,9 @@ describe("probeAndFetchFirstChunk", () => { it("throws on a non-2xx status", async () => { requestUrlMock.mockResolvedValue(res(404, [])); - await expect(probeAndFetchFirstChunk("https://x/ep.mp3", 4)).rejects.toThrow(/HTTP 404/); + await expect(probeAndFetchFirstChunk("https://example.com/ep.mp3", 4)).rejects.toThrow( + /HTTP 404/, + ); }); }); @@ -122,7 +124,7 @@ describe("writeStreamedFile", () => { .mockResolvedValueOnce(res(206, [5, 6], { "content-range": "bytes 4-5/6" })); const total = await writeStreamedFile( - "https://x/ep.mp3", + "https://example.com/ep.mp3", "out.mp3", probe({ totalSize: 6 }), undefined, @@ -145,7 +147,7 @@ describe("writeStreamedFile", () => { const a = setupAdapter(); const total = await writeStreamedFile( - "https://x/ep.mp3", + "https://example.com/ep.mp3", "out.mp3", probe({ firstChunk: new Uint8Array([1, 2, 3]).buffer, @@ -167,7 +169,7 @@ describe("writeStreamedFile", () => { requestUrlMock.mockResolvedValueOnce(res(206, [3, 4])).mockResolvedValueOnce(res(206, [5])); const total = await writeStreamedFile( - "https://x/ep.mp3", + "https://example.com/ep.mp3", "out.mp3", probe({ totalSize: null }), undefined, @@ -183,8 +185,14 @@ describe("writeStreamedFile", () => { requestUrlMock.mockResolvedValueOnce(res(500, [])); await expect( - writeStreamedFile("https://x/ep.mp3", "out.mp3", probe({ totalSize: 6 }), undefined, 2), - ).rejects.toThrow(/Range request failed/); + writeStreamedFile( + "https://example.com/ep.mp3", + "out.mp3", + probe({ totalSize: 6 }), + undefined, + 2, + ), + ).rejects.toThrow(/HTTP 500/); }); }); @@ -197,7 +205,7 @@ describe("download size cap (resource exhaustion)", () => { }), ); - await expect(probeAndFetchFirstChunk("https://x/ep.mp3", 4, 100)).rejects.toThrow( + await expect(probeAndFetchFirstChunk("https://example.com/ep.mp3", 4, 100)).rejects.toThrow( /maximum allowed size/, ); }); @@ -208,7 +216,7 @@ describe("download size cap (resource exhaustion)", () => { res(200, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], { "content-type": "audio/mpeg" }), ); - await expect(probeAndFetchFirstChunk("https://x/ep.mp3", 4, 5)).rejects.toThrow( + await expect(probeAndFetchFirstChunk("https://example.com/ep.mp3", 4, 5)).rejects.toThrow( /maximum allowed size/, ); }); @@ -221,7 +229,7 @@ describe("download size cap (resource exhaustion)", () => { await expect( writeStreamedFile( - "https://x/ep.mp3", + "https://example.com/ep.mp3", "out.mp3", probe({ totalSize: null, firstChunk: new Uint8Array([1, 2]).buffer }), undefined, @@ -239,7 +247,7 @@ describe("download size cap (resource exhaustion)", () => { await expect( writeStreamedFile( - "https://x/ep.mp3", + "https://example.com/ep.mp3", "out.mp3", probe({ firstChunk: new Uint8Array([1, 2, 3, 4, 5, 6]).buffer, @@ -260,7 +268,7 @@ describe("SSRF guard", () => { "http://127.0.0.1:8080/ep.mp3", "file:///Users/victim/.ssh/id_rsa", ])("probeAndFetchFirstChunk refuses %s without issuing a request", async (url) => { - await expect(probeAndFetchFirstChunk(url, 4)).rejects.toThrow(/Refusing/); + await expect(probeAndFetchFirstChunk(url, 4)).rejects.toThrow(/not allowed/); expect(requestUrlMock).not.toHaveBeenCalled(); }); diff --git a/src/download/streaming.ts b/src/download/streaming.ts index cf7f290..9f8f358 100644 --- a/src/download/streaming.ts +++ b/src/download/streaming.ts @@ -1,9 +1,9 @@ -import { type DataAdapter, requestUrl } from "obsidian"; +import { type DataAdapter } from "obsidian"; import { get } from "svelte/store"; import { plugin } from "../store"; import { assertFetchableUrl } from "../utility/assertFetchableUrl"; -import { encodeUrlForRequest } from "../utility/encodeUrlForRequest"; import { enforceMaxPathLength } from "../utility/enforceMaxPathLength"; +import { NetworkError, requestWithTimeout } from "../utility/networkRequest"; // ---- Streaming download (issue #113) --------------------------------------- // Mobile WebViews have a tight per-process memory budget. Buffering a whole @@ -36,6 +36,12 @@ export const DOWNLOAD_CHUNK_SIZE = 4 * 1024 * 1024; // 4 MiB per range request // turning the unbounded write into a bounded, recoverable failure. export const MAX_DOWNLOAD_SIZE = 2 * 1024 * 1024 * 1024; // 2 GiB +// `requestUrl` cannot be cancelled natively, so these are logical per-request +// ceilings enforced by the shared network boundary. Range downloads still get +// a fresh timeout for each bounded chunk. +const RANGE_PROBE_TIMEOUT_MS = 15 * 60_000; +const RANGE_CHUNK_TIMEOUT_MS = 5 * 60_000; + function tooLargeError(maxSize: number): Error { const maxMb = Math.round(maxSize / (1024 * 1024)); return new Error(`Download exceeds the maximum allowed size (${maxMb} MB). Aborting.`); @@ -82,17 +88,20 @@ export async function probeAndFetchFirstChunk( chunkSize: number = DOWNLOAD_CHUNK_SIZE, maxSize: number = MAX_DOWNLOAD_SIZE, ): Promise { - assertFetchableUrl(url); - const encodedUrl = encodeUrlForRequest(url); - const response = await requestUrl({ - url: encodedUrl, - method: "GET", - headers: { Range: `bytes=0-${chunkSize - 1}` }, - throw: false, - }); - - if (response.status !== 200 && response.status !== 206) { - throw new Error(`Could not download episode (HTTP ${response.status}).`); + let response; + try { + response = await requestWithTimeout(url, { + method: "GET", + headers: { Range: `bytes=0-${chunkSize - 1}` }, + timeoutMs: RANGE_PROBE_TIMEOUT_MS, + maxResponseBytes: maxSize, + acceptedStatuses: [200, 206], + }); + } catch (error) { + if (error instanceof NetworkError && error.code === "response-too-large") { + throw tooLargeError(maxSize); + } + throw error; } const contentType = readHeader(response.headers, "content-type") ?? ""; @@ -166,7 +175,6 @@ export async function writeStreamedFile( return written; } - const encodedUrl = encodeUrlForRequest(url); for (;;) { if (probe.totalSize !== null && written >= probe.totalSize) break; @@ -175,17 +183,23 @@ export async function writeStreamedFile( ? Math.min(written + chunkSize, probe.totalSize) - 1 : written + chunkSize - 1; - const response = await requestUrl({ - url: encodedUrl, - method: "GET", - headers: { Range: `bytes=${written}-${rangeEnd}` }, - throw: false, - }); + let response; + try { + response = await requestWithTimeout(url, { + method: "GET", + headers: { Range: `bytes=${written}-${rangeEnd}` }, + timeoutMs: RANGE_CHUNK_TIMEOUT_MS, + maxResponseBytes: maxSize - written, + acceptedStatuses: [206, 416], + }); + } catch (error) { + if (error instanceof NetworkError && error.code === "response-too-large") { + throw tooLargeError(maxSize); + } + throw error; + } if (response.status === 416) break; // requested past end of file - if (response.status !== 206) { - throw new Error(`Range request failed (HTTP ${response.status}) at byte ${written}.`); - } const chunk = response.arrayBuffer; if (chunk.byteLength === 0) break; diff --git a/src/downloadEpisode.test.ts b/src/downloadEpisode.test.ts index 8221d26..012b08f 100644 --- a/src/downloadEpisode.test.ts +++ b/src/downloadEpisode.test.ts @@ -22,6 +22,16 @@ function bytes(...values: number[]): ArrayBuffer { return new Uint8Array(values).buffer; } +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + function setupVault({ streaming = false }: { streaming?: boolean } = {}) { const present = new Set(); // vault index (getAbstractFileByPath) const disk = new Set(); // raw filesystem (adapter.exists) @@ -323,6 +333,40 @@ describe("downloadEpisodeWithNotice (download command path)", () => { }); }); + it("ignores a foreign file at the provisional URL-extension path when the sniffed final path is free (Codex #290)", async () => { + const { createBinary, present } = setupVault(); + // A different episode's file occupies the path the URL extension implies + // (.mp4). The response sniffs to .m4a, so the real destination is free and + // the fast-path check must fall through to the probe instead of throwing a + // destination collision. + present.add("Podcasts/Audio MP4 Title.mp4"); + const buffer = bytes(0x00, 0x00, 0x00, 0x18); + requestUrlMock.mockResolvedValue({ + status: 200, + headers: { "content-type": "audio/mp4" }, + arrayBuffer: buffer, + } as unknown as Awaited>); + const episode = makeEpisode({ + title: "Audio MP4 Title", + streamUrl: "https://example.com/episode.mp4", + mediaType: "audio", + }); + const setTimeoutSpy = vi + .spyOn(globalThis, "setTimeout") + .mockImplementation(() => 0 as unknown as ReturnType); + + try { + await downloadEpisodeWithNotice(episode, "Podcasts/{{title}}"); + } finally { + setTimeoutSpy.mockRestore(); + } + + expect(createBinary).toHaveBeenCalledWith("Podcasts/Audio MP4 Title.m4a", buffer); + expect(get(downloadedEpisodes)["Pod"]?.[0]).toMatchObject({ + filePath: "Podcasts/Audio MP4 Title.m4a", + }); + }); + it("saves an audio/mp4 download with a generic ISO-BMFF brand as m4a, not mp4 (Codex #213)", async () => { const { createBinary } = setupVault(); // Real ISO-BMFF: 4-byte box size, 'ftyp', then a generic 'mp42' major brand. @@ -428,7 +472,7 @@ describe("downloadEpisodeWithNotice (download command path)", () => { try { await expect( downloadEpisodeWithNotice(makeEpisode(), "Podcasts/{{title}}"), - ).rejects.toThrow(/network down/); + ).rejects.toThrow("Failed to download episode: Network request failed."); // The dismissal lives in a finally, so a rejected download still hides the // notice exactly once instead of leaving it open forever. vi.runAllTimers(); @@ -1130,10 +1174,27 @@ describe("getEpisodeAudioBuffer (issue #107)", () => { streamUrl, }); - await expect(getEpisodeAudioBuffer(ssrf)).rejects.toThrow(/Refusing/); + await expect(getEpisodeAudioBuffer(ssrf)).rejects.toThrow(/not allowed/); expect(requestUrlMock).not.toHaveBeenCalled(); }, ); + + it("redacts credentialed media targets and native transport errors", async () => { + const marker = "private-download-marker"; + requestUrlMock.mockRejectedValue(new Error(`native failure: ${marker}`)); + const privateEpisode = episode({ + title: "Private episode", + streamUrl: `https://listener:${marker}@example.com/audio.mp3?token=${marker}`, + }); + + const error = await getEpisodeAudioBuffer(privateEpisode).catch( + (caught: unknown) => caught, + ); + + expect(String(error)).toContain("Network request failed"); + expect(String(error)).not.toContain(marker); + expect(String(error)).not.toContain("listener:"); + }); }); describe("downloadEpisodeWithNotice (streaming range path)", () => { @@ -1279,6 +1340,70 @@ describe("downloadEpisodeWithNotice (streaming range path)", () => { expect(get(downloadedEpisodes)["Pod"]?.[0]?.size).toBe(8); }); + it("never reuses a sequential shared destination for a different episode", async () => { + const v = setupVault({ streaming: true }); + const template = "Podcasts/shared"; + const episodeA = makeEpisode({ title: "Episode A", podcastName: "Podcast A" }); + const episodeB = makeEpisode({ title: "Episode B", podcastName: "Podcast B" }); + requestUrlMock.mockResolvedValue( + rangeResponse(200, [0xff, 0xfb, 0x90, 0x00], { + "content-type": "audio/mpeg", + }), + ); + + await downloadEpisodeWithNotice(episodeA, template); + await expect(downloadEpisodeWithNotice(episodeB, template)).rejects.toThrow( + "A different episode already occupies the selected destination.", + ); + + // Episode B pays one Range probe before the collision surfaces: the + // provisional URL-extension path cannot distinguish a real collision from a + // wrong URL extension, so the check runs on the sniffed final path. + expect(requestUrlMock).toHaveBeenCalledTimes(2); + expect(v.writeBinary).toHaveBeenCalledOnce(); + expect(get(downloadedEpisodes)["Podcast A"]?.[0]).toMatchObject({ + filePath: "Podcasts/shared.mp3", + title: "Episode A", + }); + expect(get(downloadedEpisodes)["Podcast B"]).toBeUndefined(); + + // The registered owner still reuses the fast path with no network traffic. + await downloadEpisodeWithNotice(episodeA, template); + expect(requestUrlMock).toHaveBeenCalledTimes(2); + expect(get(downloadedEpisodes)["Podcast A"]).toHaveLength(1); + }); + + it("holds one normalized destination in flight and releases it after failure", async () => { + setupVault(); + const response = deferred>>(); + const template = "Podcasts/shared"; + const episodeA = makeEpisode({ title: "Episode A", podcastName: "Podcast A" }); + const episodeB = makeEpisode({ title: "Episode B", podcastName: "Podcast B" }); + requestUrlMock.mockImplementationOnce( + () => response.promise as unknown as ReturnType, + ); + + const first = downloadEpisodeWithNotice(episodeA, template); + await vi.waitFor(() => expect(requestUrlMock).toHaveBeenCalledOnce()); + + await expect(downloadEpisodeWithNotice(episodeB, template)).rejects.toThrow( + "A download is already in progress for the selected destination.", + ); + expect(requestUrlMock).toHaveBeenCalledOnce(); + + response.reject(new Error("first attempt failed")); + await expect(first).rejects.toThrow("Network request failed."); + + requestUrlMock.mockResolvedValueOnce( + rangeResponse(200, [0xff, 0xfb, 0x90, 0x00], { + "content-type": "audio/mpeg", + }), + ); + await downloadEpisodeWithNotice(episodeB, template); + expect(requestUrlMock).toHaveBeenCalledTimes(2); + expect(get(downloadedEpisodes)["Podcast B"]?.[0]?.filePath).toBe("Podcasts/shared.mp3"); + }); + it("removes the partial file via the adapter (not yet vault-indexed) and rethrows on a mid-stream failure", async () => { const v = setupVault({ streaming: true }); requestUrlMock @@ -1292,7 +1417,7 @@ describe("downloadEpisodeWithNotice (streaming range path)", () => { await expect( downloadEpisodeWithNotice(makeEpisode(), "Podcasts/{{title}}"), - ).rejects.toThrow(/Range request failed/); + ).rejects.toThrow(/HTTP 500/); // The partial was written through the adapter, so it isn't in the vault // index — cleanup must fall back to adapter.remove (#218). diff --git a/src/downloadEpisode.ts b/src/downloadEpisode.ts index ebf6efb..812ec34 100644 --- a/src/downloadEpisode.ts +++ b/src/downloadEpisode.ts @@ -1,4 +1,4 @@ -import { Notice, TFile, requestUrl } from "obsidian"; +import { normalizePath, Notice, TFile } from "obsidian"; import { get } from "svelte/store"; import { downloadedEpisodes, plugin } from "./store"; import { @@ -7,8 +7,6 @@ import { } from "./TemplateEngine"; import type { Episode, EpisodeMediaType } from "./types/Episode"; import type { LocalEpisode } from "./types/LocalEpisode"; -import { assertFetchableUrl } from "./utility/assertFetchableUrl"; -import { encodeUrlForRequest } from "./utility/encodeUrlForRequest"; import { enforceMaxPathLength } from "./utility/enforceMaxPathLength"; import { ensureFolderExists } from "./utility/ensureFolderExists"; import { isLocalFile } from "./utility/isLocalFile"; @@ -31,8 +29,12 @@ import { probeAndFetchFirstChunk, sweepStalePartials, writeStreamedFile, + MAX_DOWNLOAD_SIZE, } from "./download/streaming"; import { detectAudioFileExtension } from "./download/mediaSignatures"; +import { NetworkError, requestWithTimeout } from "./utility/networkRequest"; + +const WHOLE_FILE_DOWNLOAD_TIMEOUT_MS = 15 * 60_000; function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); @@ -54,37 +56,47 @@ interface DownloadedFile { // and transcription's getEpisodeAudioBuffer still need the entire buffer at once. // The Download command streams instead — see downloadEpisodeToDisk. async function downloadFile(url: string): Promise { - assertFetchableUrl(url); - const encodedUrl = encodeUrlForRequest(url); + let response; try { - const response = await requestUrl({ url: encodedUrl, method: "GET" }); - - if (response.status !== 200) { - throw new Error("Could not download episode."); + response = await requestWithTimeout(url, { + method: "GET", + timeoutMs: WHOLE_FILE_DOWNLOAD_TIMEOUT_MS, + maxResponseBytes: MAX_DOWNLOAD_SIZE, + acceptedStatuses: [200], + }); + } catch (error: unknown) { + if (error instanceof NetworkError) { + throw new Error(`Failed to download episode: ${error.message}`); } + throw new Error("Failed to download episode."); + } - const data = response.arrayBuffer; - const contentType = - response.headers["content-type"] ?? response.headers["Content-Type"] ?? ""; + const data = response.arrayBuffer; + const contentType = response.headers["content-type"] ?? response.headers["Content-Type"] ?? ""; - return { data, contentType, byteLength: data.byteLength }; - } catch (error: unknown) { - throw new Error(`Failed to download ${url}:\n\n${getErrorMessage(error)}`); - } + return { data, contentType, byteLength: data.byteLength }; } function parentFolderPath(filePath: string): string { return filePath.split("/").slice(0, -1).join("/"); } -// In-flight downloads keyed by episode identity: collapses duplicate/concurrent -// requests for the same episode (e.g. a double-tap, or several queued at once) -// into a single transfer so they can't stack N full downloads in memory. -const downloadsInFlight = new Map>(); +// One destination family can have only one live download. The map is acquired +// before any request or allocation and released only after the underlying work +// and cleanup settle. Different episodes never share another download's result. +const downloadsInFlight = new Map(); + +function normalizedDestinationFamily(downloadPathTemplate: string, episode: Episode): string { + return normalizePath(safeDownloadBasename(downloadPathTemplate, episode)); +} + +function downloadAlreadyInProgressError(): Error { + return new Error("A download is already in progress for the selected destination."); +} -// Temp paths of downloads currently streaming, so the stale-partial sweep never -// deletes a concurrent download's live temp when it sweeps the shared folder. -const activePartialPaths = new Set(); +function downloadDestinationCollisionError(): Error { + return new Error("A different episode already occupies the selected destination."); +} // Single source of truth for "what extension and on-disk path does this download // get, and is it even playable". Shared by the streaming and legacy paths so the @@ -106,16 +118,55 @@ function resolveDownloadTarget( }; } +function registeredDownloadOwnsPath(episode: Episode, filePath: string): boolean { + const normalizedPath = normalizePath(filePath); + const registered = downloadedEpisodes.getEpisode(episode); + if (!registered || normalizePath(registered.filePath) !== normalizedPath) return false; + + const anotherOwner = Object.values(get(downloadedEpisodes)) + .flat() + .some( + (candidate) => + candidate !== registered && normalizePath(candidate.filePath) === normalizedPath, + ); + if (anotherOwner) return false; + + if (isLocalFile(episode)) { + return Boolean(episode.filePath && normalizePath(episode.filePath) === normalizedPath); + } + return isSameMediaSource(registered.streamUrl, episode.streamUrl); +} + +// Fast-path variant: the path here is only the URL-extension guess, so a foreign +// occupant is not a collision yet - the sniffed final path may differ. Returns a +// size only when this episode already owns the file. +function ownedExistingDownloadSize(episode: Episode, filePath: string): number | undefined { + const existing = get(plugin).app.vault.getAbstractFileByPath(filePath); + if (!(existing instanceof TFile)) return undefined; + if (!registeredDownloadOwnsPath(episode, filePath)) return undefined; + return downloadedEpisodes.getEpisode(episode)?.size; +} + +// Resolved-destination variant: the final path is known, so a foreign occupant +// is a hard collision. +function reusableExistingDownloadSize(episode: Episode, filePath: string): number | undefined { + const existing = get(plugin).app.vault.getAbstractFileByPath(filePath); + if (!(existing instanceof TFile)) return undefined; + if (!registeredDownloadOwnsPath(episode, filePath)) { + throw downloadDestinationCollisionError(); + } + return downloadedEpisodes.getEpisode(episode)?.size; +} + // Download an episode to a vault file with bounded memory. Streams via Range // chunks when the adapter supports binary append; otherwise falls back to the // legacy whole-file buffer (so a non-appendable adapter is never truncated). // Returns the on-disk path. -async function downloadEpisodeToDisk( +async function startDownloadEpisodeToDisk( episode: Episode, downloadPathTemplate: string, onProgress?: (written: number, total: number | null) => void, ): Promise { - const { app } = get(plugin); // Fast path: if this episode is already on disk under the extension its URL // implies, skip the (potentially multi-MB) Range probe entirely. The probe is // only needed to discover the extension when the URL lacks one, or to confirm @@ -123,9 +174,9 @@ async function downloadEpisodeToDisk( const urlExtension = getUrlExtension(episode.streamUrl); if (urlExtension) { const provisionalPath = safeDownloadFilePath(downloadPathTemplate, episode, urlExtension); - const cached = app.vault.getAbstractFileByPath(provisionalPath); - if (cached instanceof TFile) { - downloadedEpisodes.addEpisode(episode, provisionalPath, cached.stat.size); + const cachedSize = ownedExistingDownloadSize(episode, provisionalPath); + if (cachedSize !== undefined) { + downloadedEpisodes.addEpisode(episode, provisionalPath, cachedSize); return provisionalPath; } } @@ -142,6 +193,11 @@ async function downloadEpisodeToDisk( data, contentType, ); + const existingSize = reusableExistingDownloadSize(episode, filePath); + if (existingSize !== undefined) { + downloadedEpisodes.addEpisode(episode, filePath, existingSize); + return filePath; + } await createEpisodeFile({ episode, downloadPathTemplate, data, extension }); return filePath; } @@ -153,49 +209,51 @@ async function downloadEpisodeToDisk( probe.firstChunk, probe.contentType, ); - - const existing = app.vault.getAbstractFileByPath(filePath); - if (existing instanceof TFile) { - downloadedEpisodes.addEpisode(episode, filePath, existing.stat.size); + const existingSize = reusableExistingDownloadSize(episode, filePath); + if (existingSize !== undefined) { + downloadedEpisodes.addEpisode(episode, filePath, existingSize); return filePath; } await ensureFolderExists(parentFolderPath(filePath)); // Stream to a sibling temp the vault watchers never see, then move the finished - // file into place as one rename — so watcher plugins (Waypoint, Dataview, - // Obsidian Git, ...) don't get a per-chunk modify storm on the growing media - // file and re-scan it into an OOM crash. See streaming.ts for the mechanism. + // file into place as one rename - so watcher plugins don't get a per-chunk + // modify storm on the growing media file and re-scan it into an OOM crash. const tmpPath = partialPathFor(filePath); - activePartialPaths.add(tmpPath); let moved = false; try { - // Reclaim temps orphaned by a previous download killed mid-stream (the very - // crash this fix addresses). The active set protects any concurrent - // download's live temp from being swept. - await sweepStalePartials(parentFolderPath(filePath), (path) => - activePartialPaths.has(path), - ); + // When another destination is active, keep every partial. A later solo + // download will sweep any orphan once no live writer could own it. + await sweepStalePartials(parentFolderPath(filePath), () => downloadsInFlight.size > 1); const total = await writeStreamedFile(episode.streamUrl, tmpPath, probe, onProgress); if (probe.totalSize !== null && total !== probe.totalSize) { throw new Error(`Incomplete download: got ${total} of ${probe.totalSize} bytes.`); } - // Only a fully-written, size-verified file is exposed to the vault — as one - // atomic rename, so a partial never even momentarily occupies the real path. await moveIntoPlace(tmpPath, filePath); moved = true; downloadedEpisodes.addEpisode(episode, filePath, total); return filePath; } finally { - // If the file never made it into place (stream, size or move failure), drop - // the temp so a later attempt can't mistake a truncated partial for a - // complete download — the legacy createBinary path was atomic, chunked - // appendBinary is not. deleteEpisodeFile is a no-op when the temp is absent. if (!moved) await deleteEpisodeFile(tmpPath); - activePartialPaths.delete(tmpPath); + } +} + +async function downloadEpisodeToDisk( + episode: Episode, + downloadPathTemplate: string, + onProgress?: (written: number, total: number | null) => void, +): Promise { + const destination = normalizedDestinationFamily(downloadPathTemplate, episode); + if (downloadsInFlight.has(destination)) throw downloadAlreadyInProgressError(); + downloadsInFlight.set(destination, true); + try { + return await startDownloadEpisodeToDisk(episode, downloadPathTemplate, onProgress); + } finally { + downloadsInFlight.delete(destination); } } @@ -218,21 +276,6 @@ export default async function downloadEpisodeWithNotice( // exactly once (#DL-03), then rethrows so the (fire-and-forget) caller can log // it. Callers that need the resulting path call downloadEpisodeToDisk directly. try { - // Dedupe by episode identity (podcast + title — the same key the download - // registry uses), so a double-tap collapses onto one transfer while two - // genuinely different episodes never block each other. - const key = `${episode.podcastName}\u0000${episode.title}`; - - const inProgress = downloadsInFlight.get(key); - if (inProgress) { - update((bodyEl) => - bodyEl.createEl("p", { text: "Already downloading this episode..." }), - ); - await inProgress; - showSuccess(); - return; - } - update((bodyEl) => bodyEl.createEl("p", { text: "Starting download..." })); const work = downloadEpisodeToDisk(episode, downloadPathTemplate, (written, total) => { @@ -240,13 +283,7 @@ export default async function downloadEpisodeWithNotice( const pct = total && total > 0 ? ` ${Math.round((written / total) * 100)}%` : ""; update((bodyEl) => bodyEl.createEl("p", { text: `Downloading...${pct} (${mb} MB)` })); }); - downloadsInFlight.set(key, work); - - try { - await work; - } finally { - downloadsInFlight.delete(key); - } + await work; showSuccess(); } catch (error: unknown) { diff --git a/src/getUniversalPodcastLink.test.ts b/src/getUniversalPodcastLink.test.ts index ebac192..fed6d05 100644 --- a/src/getUniversalPodcastLink.test.ts +++ b/src/getUniversalPodcastLink.test.ts @@ -1,11 +1,11 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { requestUrl } from "obsidian"; import getUniversalPodcastLink from "./getUniversalPodcastLink"; import { queryiTunesPodcasts } from "./iTunesAPIConsumer"; import { savedFeeds } from "./store"; import type { IAPI } from "./API/IAPI"; import type { Episode } from "./types/Episode"; +import { fetchJsonWithTimeout } from "./utility/networkRequest"; const noticeSpy = vi.fn(); @@ -13,7 +13,6 @@ vi.mock("obsidian", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - requestUrl: vi.fn(), // A spy class so we can assert which user-facing messages were shown. Notice: class { message?: string; @@ -30,7 +29,11 @@ vi.mock("./iTunesAPIConsumer", () => ({ queryiTunesPodcasts: vi.fn(), })); -const requestUrlMock = vi.mocked(requestUrl); +vi.mock("./utility/networkRequest", () => ({ + fetchJsonWithTimeout: vi.fn(), +})); + +const requestMock = vi.mocked(fetchJsonWithTimeout); const queryiTunesMock = vi.mocked(queryiTunesPodcasts); const podcast: Episode = { @@ -72,10 +75,9 @@ beforeEach(() => { vi.clearAllMocks(); vi.spyOn(console, "error").mockImplementation(() => {}); savedFeeds.set({}); - requestUrlMock.mockResolvedValue({ - status: 200, - json: { episodes: [{ episodeId: "ep-123", title: "Episode One" }] }, - } as never); + requestMock.mockResolvedValue({ + episodes: [{ episodeId: "ep-123", title: "Episode One" }], + }); queryiTunesMock.mockResolvedValue([]); setClipboard(); }); @@ -99,8 +101,10 @@ describe("getUniversalPodcastLink", () => { await getUniversalPodcastLink(api); expect(queryiTunesMock).not.toHaveBeenCalled(); - expect(requestUrlMock).toHaveBeenCalledWith({ - url: "https://pod.link/555.json?limit=1000", + expect(requestMock).toHaveBeenCalledWith("https://pod.link/555.json?limit=1000", { + timeoutMs: 15_000, + maxResponseBytes: 4 * 1024 * 1024, + acceptedStatuses: [200], }); expect(writeTextMock).toHaveBeenCalledWith("https://pod.link/555/episode/ep-123"); expect(noticeMessages()).toContain("Universal episode link copied to clipboard."); @@ -192,7 +196,7 @@ describe("getUniversalPodcastLink", () => { await getUniversalPodcastLink(api); - expect(requestUrlMock).not.toHaveBeenCalled(); + expect(requestMock).not.toHaveBeenCalled(); expect(writeTextMock).not.toHaveBeenCalled(); expect(noticeMessages()).toContain( 'Could not find "Example Show" on Apple Podcasts to build a universal link.', diff --git a/src/getUniversalPodcastLink.ts b/src/getUniversalPodcastLink.ts index c32a974..567fc4d 100644 --- a/src/getUniversalPodcastLink.ts +++ b/src/getUniversalPodcastLink.ts @@ -1,8 +1,21 @@ -import { requestUrl, Notice } from "obsidian"; +import { Notice } from "obsidian"; import { get } from "svelte/store"; import type { IAPI } from "./API/IAPI"; import { queryiTunesPodcasts } from "./iTunesAPIConsumer"; import { savedFeeds } from "./store"; +import { NetworkError, fetchJsonWithTimeout } from "./utility/networkRequest"; + +const POD_LINK_REQUEST_TIMEOUT_MS = 15_000; +const MAX_POD_LINK_RESPONSE_BYTES = 4 * 1024 * 1024; + +interface PodLinkEpisode { + episodeId: string; + title: string; +} + +interface PodLinkResponse { + episodes: PodLinkEpisode[]; +} const normalizeFeedUrl = (url: string | undefined): string => (url ?? "").trim().replace(/\/+$/, "").toLowerCase(); @@ -59,18 +72,23 @@ export default async function getUniversalPodcastLink(api: IAPI) { } const podLinkUrl = `https://pod.link/${collectionId}.json?limit=1000`; - const res = await requestUrl({ - url: podLinkUrl, + const data = await fetchJsonWithTimeout(podLinkUrl, { + timeoutMs: POD_LINK_REQUEST_TIMEOUT_MS, + maxResponseBytes: MAX_POD_LINK_RESPONSE_BYTES, + acceptedStatuses: [200], }); - if (res.status !== 200) { - throw new Error(`Failed to get response from pod.link: ${podLinkUrl}`); - } - const targetTitle = itunesTitle ?? title; - const ep = res.json.episodes.find( - (episode: { episodeId: string; title: string; [key: string]: string }) => + if (!data || typeof data !== "object" || !Array.isArray(data.episodes)) { + throw new NetworkError("invalid-response"); + } + const ep = data.episodes.find( + (episode) => + episode !== null && + typeof episode === "object" && + typeof episode.episodeId === "string" && + typeof episode.title === "string" && episode.title === targetTitle, ); if (!ep) { @@ -81,9 +99,9 @@ export default async function getUniversalPodcastLink(api: IAPI) { } url = `https://pod.link/${collectionId}/episode/${ep.episodeId}`; - } catch (error) { + } catch { new Notice("Could not get podcast link."); - console.error(error); + console.error("Could not resolve universal podcast link."); return; } @@ -95,8 +113,8 @@ export default async function getUniversalPodcastLink(api: IAPI) { try { await navigator.clipboard.writeText(url); new Notice("Universal episode link copied to clipboard."); - } catch (error) { - console.error(error); + } catch { + console.error("Could not copy universal podcast link to the clipboard."); new Notice(`Could not copy to clipboard. Episode link: ${url}`); } } else { diff --git a/src/iTunesAPIConsumer.test.ts b/src/iTunesAPIConsumer.test.ts new file mode 100644 index 0000000..ab32a48 --- /dev/null +++ b/src/iTunesAPIConsumer.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchJsonWithTimeout, NetworkError } from "./utility/networkRequest"; +import { queryiTunesPodcasts } from "./iTunesAPIConsumer"; + +vi.mock("./utility/networkRequest", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchJsonWithTimeout: vi.fn() }; +}); + +const fetchJsonMock = vi.mocked(fetchJsonWithTimeout); + +describe("queryiTunesPodcasts", () => { + beforeEach(() => { + fetchJsonMock.mockReset(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => vi.restoreAllMocks()); + + it("uses a bounded request and projects search results", async () => { + fetchJsonMock.mockResolvedValue({ + results: [ + { + collectionName: "Example Show", + feedUrl: "https://feeds.example.com/show.xml", + artworkUrl100: "https://images.example.com/show.jpg", + collectionId: "123", + }, + ], + }); + + await expect(queryiTunesPodcasts("example & show")).resolves.toEqual([ + { + title: "Example Show", + url: "https://feeds.example.com/show.xml", + artworkUrl: "https://images.example.com/show.jpg", + collectionId: "123", + }, + ]); + expect(fetchJsonMock).toHaveBeenCalledWith( + expect.stringMatching(/^https:\/\/itunes\.apple\.com\/search\?/), + { + timeoutMs: 15_000, + maxResponseBytes: 2 * 1024 * 1024, + acceptedStatuses: [200], + }, + ); + }); + + it("turns an invalid response shape into a redacted boundary error", async () => { + const marker = "private-query-value"; + fetchJsonMock.mockResolvedValue({ results: null } as never); + + const error = await queryiTunesPodcasts(marker).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(NetworkError); + expect((error as NetworkError).code).toBe("invalid-response"); + expect(String(error)).not.toContain(marker); + expect(console.error).toHaveBeenCalledWith( + "iTunes search failed.", + "invalid-response", + undefined, + ); + }); +}); diff --git a/src/iTunesAPIConsumer.ts b/src/iTunesAPIConsumer.ts index a50e909..44aa817 100644 --- a/src/iTunesAPIConsumer.ts +++ b/src/iTunesAPIConsumer.ts @@ -1,5 +1,8 @@ import type { PodcastFeed } from "./types/PodcastFeed"; -import { requestWithTimeout } from "./utility/networkRequest"; +import { NetworkError, fetchJsonWithTimeout } from "./utility/networkRequest"; + +const ITUNES_REQUEST_TIMEOUT_MS = 15_000; +const MAX_ITUNES_RESPONSE_BYTES = 2 * 1024 * 1024; interface iTunesResult { collectionName: string; @@ -20,23 +23,30 @@ export async function queryiTunesPodcasts(query: string): Promise url.searchParams.append("kind", "podcast"); try { - const response = await requestWithTimeout(url.href, { timeoutMs: 15000 }); - const data = response.json as iTunesSearchResponse; + const data = await fetchJsonWithTimeout(url.href, { + timeoutMs: ITUNES_REQUEST_TIMEOUT_MS, + maxResponseBytes: MAX_ITUNES_RESPONSE_BYTES, + acceptedStatuses: [200], + }); + if (!data || typeof data !== "object" || !Array.isArray(data.results)) { + throw new NetworkError("invalid-response"); + } - return (data.results || []).map((d) => ({ + return data.results.map((d) => ({ title: d.collectionName, url: d.feedUrl, artworkUrl: d.artworkUrl100, collectionId: d.collectionId, })); } catch (error) { - // Log every failure (including a malformed-JSON SyntaxError from - // response.json), not just NetworkError, so swallowed errors are - // diagnosable, then rethrow so the caller can distinguish a genuine - // failure from a legitimate empty result set (SA-01). Returning [] here - // collapsed both into the benign "No results." message. - const message = error instanceof Error ? error.message : String(error); - console.error(`iTunes search failed: ${message}`); - throw error instanceof Error ? error : new Error(message); + // Keep diagnostics useful without copying a target, query, native error, or + // response body into the console. NetworkError messages and status values are + // deliberately redacted by the shared boundary. + if (error instanceof NetworkError) { + console.error("iTunes search failed.", error.code, error.status); + throw error; + } + console.error("iTunes search response is invalid."); + throw new NetworkError("invalid-response"); } } diff --git a/src/network/LiteralTargetClassifier.test.ts b/src/network/LiteralTargetClassifier.test.ts deleted file mode 100644 index 1eefa81..0000000 --- a/src/network/LiteralTargetClassifier.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - MAX_NETWORK_TARGET_BYTES, - MAX_RESOLVED_ADDRESS_TEXT_LENGTH, - classifyResolvedAddress, - isResolvedAddressLiteral, - parseTargetPolicyView, - resolvedAddressesEqual, -} from "./LiteralTargetClassifier"; - -describe("literal network target classification", () => { - it.each([ - "0.0.0.0", - "10.0.0.1", - "100.64.0.1", - "127.0.0.1", - "169.254.169.254", - "172.31.255.255", - "192.0.0.9", - "192.0.2.1", - "192.31.196.1", - "192.52.193.1", - "192.88.99.1", - "192.168.1.1", - "192.175.48.1", - "198.18.0.1", - "198.51.100.1", - "203.0.113.1", - "224.0.0.1", - "255.255.255.255", - ])("fails closed for a special IPv4 address: %s", (address) => { - expect(classifyResolvedAddress(address)).toBe("non-public-or-special-literal"); - }); - - it.each(["1.1.1.1", "8.8.8.8", "93.184.216.34", "223.255.255.254"])( - "accepts an ordinary public IPv4 address: %s", - (address) => { - expect(classifyResolvedAddress(address)).toBe("ordinary-public-literal"); - }, - ); - - it("compares socket addresses canonically without accepting non-addresses", () => { - expect(resolvedAddressesEqual("2001:4860::1", "2001:4860:0:0:0:0:0:1")).toBe(true); - expect(resolvedAddressesEqual("8.8.8.8", "::ffff:8.8.8.8")).toBe(true); - expect(resolvedAddressesEqual("8.8.8.8", "8.8.4.4")).toBe(false); - expect(resolvedAddressesEqual("private.example", "private.example")).toBe(false); - }); - - it.each([ - "::", - "::1", - "64:ff9b::c000:201", - "64:ff9b:1::1", - "100::1", - "100:0:0:1::1", - "2001::1", - "2001:db8::1", - "2002::1", - "2620:4f:8000::1", - "3fff::1", - "5f00::1", - "fc00::1", - "fe80::1", - "fec0::1", - "ff02::1", - "4000::1", - ])("fails closed for a special or non-global IPv6 address: %s", (address) => { - expect(classifyResolvedAddress(address)).toBe("non-public-or-special-literal"); - }); - - it.each(["2001:4860:4860::8888", "2606:4700:4700::1111"])( - "accepts an ordinary public IPv6 address: %s", - (address) => { - expect(classifyResolvedAddress(address)).toBe("ordinary-public-literal"); - }, - ); - - it("classifies IPv4-mapped IPv6 addresses by their embedded address", () => { - expect(classifyResolvedAddress("::ffff:127.0.0.1")).toBe("non-public-or-special-literal"); - expect(classifyResolvedAddress("::ffff:8.8.8.8")).toBe("ordinary-public-literal"); - }); - - it.each([ - "", - " 8.8.8.8", - "999.1.1.1", - "8.8.8", - "example.com", - "fe80::1%en0", - "2001:::1", - "[2001:db8::1", - "[2001:4860::1]", - "32.1.72.96::", - "2001:4860:32.1.72.96::", - "8".repeat(MAX_RESOLVED_ADDRESS_TEXT_LENGTH + 1), - ])("treats a non-address resolver result as non-public: %s", (address) => { - expect(classifyResolvedAddress(address)).toBe("non-public-or-special-literal"); - expect(isResolvedAddressLiteral(address)).toBe(false); - }); - - it.each(["8.8.8.8", "127.0.0.1", "2001:4860::1", "::1", "::ffff:8.8.8.8"])( - "recognizes a strict resolver address spelling: %s", - (address) => { - expect(isResolvedAddressLiteral(address)).toBe(true); - }, - ); - - it.each([ - ["https://127.0.0.1/feed", "non-public-or-special-literal"], - ["https://2130706433/feed", "non-public-or-special-literal"], - ["https://0x7f000001/feed", "non-public-or-special-literal"], - ["https://0177.0.0.1/feed", "non-public-or-special-literal"], - ["https://192.168.1/feed", "non-public-or-special-literal"], - ["https://8.8.8.8/feed", "ordinary-public-literal"], - ["https://[::1]/feed", "non-public-or-special-literal"], - ["https://[2606:4700:4700::1111]/feed", "ordinary-public-literal"], - ["https://feeds.example.com/feed", "hostname-requires-resolution"], - ] as const)("classifies a parsed target host: %s", (target, classification) => { - const result = parseTargetPolicyView(target); - expect(result.ok).toBe(true); - if (result.ok) expect(result.value.hostClassification).toBe(classification); - }); - - it.each([ - "http://localhost/feed", - "https://api.localhost/feed", - "https://printer.local/feed", - "https://service.internal/feed", - "https://router.home.arpa/feed", - "https://single-label/feed", - ])("fails closed for a locally scoped hostname: %s", (target) => { - const result = parseTargetPolicyView(target); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.value.hostClassification).toBe("non-public-or-special-literal"); - } - }); - - it("produces a canonical policy view without returning a transmission target", () => { - const result = parseTargetPolicyView("HTTPS://User:Pass@ExAmPle.COM.:443/a%2Fb?sig=A%2BB"); - expect(result).toEqual({ - ok: true, - value: { - protocol: "https:", - normalizedHostname: "example.com", - normalizedOrigin: "https://example.com", - port: 443, - hasCredentials: true, - hostClassification: "hostname-requires-resolution", - }, - }); - if (result.ok) { - expect(Object.isFrozen(result.value)).toBe(true); - expect(result.value).not.toHaveProperty("target"); - expect(JSON.stringify(result.value)).not.toContain("sig=A"); - } - }); - - it("canonicalizes IPv6 origins and preserves non-default ports in policy only", () => { - const result = parseTargetPolicyView("http://[2001:4860:4860::8888]:8080/feed"); - expect(result).toEqual({ - ok: true, - value: { - protocol: "http:", - normalizedHostname: "2001:4860:4860::8888", - normalizedOrigin: "http://[2001:4860:4860::8888]:8080", - port: 8080, - hasCredentials: false, - hostClassification: "ordinary-public-literal", - }, - }); - }); - - it.each([ - [undefined, "empty"], - ["", "empty"], - [" https://example.com", "empty"], - ["https://example.com ", "empty"], - ["https://exa\n mple.com", "unsafe-unicode"], - [`https://exa${String.fromCharCode(0xd800)}mple.com`, "unsafe-unicode"], - ["mailto:owner@example.com", "unsupported-scheme"], - ["file:///etc/passwd", "unsupported-scheme"], - ["ftp://example.com/feed", "unsupported-scheme"], - ["https://example.com/#fragment", "malformed"], - ["https:\\example.com\\feed", "malformed"], - ["http:example.com/feed", "malformed"], - ["http:/example.com/feed", "malformed"], - ["http:///example.com/feed", "malformed"], - ["https:////example.com/feed", "malformed"], - ["not a target", "unsafe-unicode"], - ["https://", "malformed"], - ] as const)("rejects an invalid target without echoing it: %s", (target, reason) => { - const result = parseTargetPolicyView(target); - expect(result).toEqual({ ok: false, reason }); - if (typeof target === "string" && target.length > 0) { - expect(JSON.stringify(result)).not.toContain(target); - } - }); - - it.each(["https://@example.com/feed", "https://:@example.com/feed"])( - "detects even empty userinfo syntax: %s", - (target) => { - const result = parseTargetPolicyView(target); - expect(result.ok).toBe(true); - if (result.ok) expect(result.value.hasCredentials).toBe(true); - }, - ); - - it("bounds targets by UTF-8 bytes rather than UTF-16 code units", () => { - const prefix = "https://example.com/"; - const oversized = `${prefix}${"x".repeat(MAX_NETWORK_TARGET_BYTES - prefix.length + 1)}`; - expect(parseTargetPolicyView(oversized)).toEqual({ ok: false, reason: "too-large" }); - - const multibyte = `${prefix}${"é".repeat(MAX_NETWORK_TARGET_BYTES / 2)}`; - expect(multibyte.length).toBeLessThan(MAX_NETWORK_TARGET_BYTES); - expect(parseTargetPolicyView(multibyte)).toEqual({ ok: false, reason: "too-large" }); - }); -}); diff --git a/src/network/LiteralTargetClassifier.ts b/src/network/LiteralTargetClassifier.ts deleted file mode 100644 index 5bcc0f0..0000000 --- a/src/network/LiteralTargetClassifier.ts +++ /dev/null @@ -1,391 +0,0 @@ -export const MAX_NETWORK_TARGET_BYTES = 16 * 1024; -export const MAX_RESOLVED_ADDRESS_TEXT_LENGTH = 64; - -export type HostClassification = - | "ordinary-public-literal" - | "non-public-or-special-literal" - | "hostname-requires-resolution"; - -export type LiteralAddressClassification = Exclude< - HostClassification, - "hostname-requires-resolution" ->; - -export type NetworkProtocol = "http:" | "https:"; - -export interface TargetPolicyView { - readonly protocol: NetworkProtocol; - /** Canonical policy-only hostname. IPv6 brackets and a terminal DNS dot are removed. */ - readonly normalizedHostname: string; - /** Canonical policy-only origin. Never use this value as the transmitted target. */ - readonly normalizedOrigin: string; - readonly port: number; - readonly hasCredentials: boolean; - readonly hostClassification: HostClassification; -} - -export type TargetPolicyFailureReason = - | "empty" - | "too-large" - | "malformed" - | "unsupported-scheme" - | "unsafe-unicode"; - -export type TargetPolicyResult = - | { readonly ok: true; readonly value: TargetPolicyView } - | { readonly ok: false; readonly reason: TargetPolicyFailureReason }; - -interface AddressRange { - readonly network: bigint; - readonly prefixLength: number; -} - -const IPV4_BIT_LENGTH = 32; -const IPV6_BIT_LENGTH = 128; - -const IPV4_BLOCKED_RANGES = [ - ["0.0.0.0", 8], - ["10.0.0.0", 8], - ["100.64.0.0", 10], - ["127.0.0.0", 8], - ["169.254.0.0", 16], - ["172.16.0.0", 12], - ["192.0.0.0", 24], - ["192.0.2.0", 24], - ["192.31.196.0", 24], - ["192.52.193.0", 24], - ["192.88.99.0", 24], - ["192.168.0.0", 16], - ["192.175.48.0", 24], - ["198.18.0.0", 15], - ["198.51.100.0", 24], - ["203.0.113.0", 24], - ["224.0.0.0", 4], - ["240.0.0.0", 4], -] as const; - -const IPV6_BLOCKED_RANGES = [ - ["::", 128], - ["::1", 128], - ["64:ff9b::", 96], - ["64:ff9b:1::", 48], - ["100::", 64], - ["100:0:0:1::", 64], - ["2001::", 23], - ["2001:db8::", 32], - ["2002::", 16], - ["2620:4f:8000::", 48], - ["3fff::", 20], - ["5f00::", 16], - ["fc00::", 7], - ["fe80::", 10], - ["fec0::", 10], - ["ff00::", 8], -] as const; - -function parseCanonicalIpv4(value: string): bigint | undefined { - const parts = value.split("."); - if (parts.length !== 4) return undefined; - - let address = 0n; - for (const part of parts) { - if (!/^(?:0|[1-9][0-9]{0,2})$/.test(part)) return undefined; - const octet = Number(part); - if (octet > 255) return undefined; - address = (address << 8n) | BigInt(octet); - } - return address; -} - -function parseIpv6(value: string): bigint | undefined { - const candidate = value.toLowerCase(); - if (candidate.length === 0 || candidate.includes("%")) return undefined; - - const compressionIndex = candidate.indexOf("::"); - if (compressionIndex !== -1 && candidate.indexOf("::", compressionIndex + 2) !== -1) { - return undefined; - } - - const parseSide = (side: string): string[] | undefined => { - if (side.length === 0) return []; - const pieces = side.split(":"); - if (pieces.some((piece) => piece.length === 0)) return undefined; - return pieces; - }; - - const left = parseSide( - compressionIndex === -1 ? candidate : candidate.slice(0, compressionIndex), - ); - const right = parseSide(compressionIndex === -1 ? "" : candidate.slice(compressionIndex + 2)); - if (!left || !right) return undefined; - - const sideWithLastPiece = right.length > 0 ? right : left; - const lastPiece = sideWithLastPiece[sideWithLastPiece.length - 1]; - if (lastPiece?.includes(".")) { - // An embedded dotted quad is the final 32 bits of an IPv6 spelling. It - // cannot precede a trailing compression marker such as 192.0.2.1::. - if (compressionIndex !== -1 && right.length === 0) return undefined; - const ipv4 = parseCanonicalIpv4(lastPiece); - if (ipv4 === undefined) return undefined; - sideWithLastPiece.splice( - sideWithLastPiece.length - 1, - 1, - Number((ipv4 >> 16n) & 0xffffn).toString(16), - Number(ipv4 & 0xffffn).toString(16), - ); - } - - const combined = [...left, ...right]; - if (combined.some((piece) => piece.includes("."))) return undefined; - if (combined.some((piece) => !/^[0-9a-f]{1,4}$/.test(piece))) return undefined; - if (compressionIndex === -1 && combined.length !== 8) return undefined; - if (compressionIndex !== -1 && combined.length >= 8) return undefined; - - const missing = 8 - combined.length; - const hextets = - compressionIndex === -1 - ? combined - : [...left, ...Array.from({ length: missing }, () => "0"), ...right]; - if (hextets.length !== 8) return undefined; - - let address = 0n; - for (const hextet of hextets) address = (address << 16n) | BigInt(`0x${hextet}`); - return address; -} - -function rangeFromIpv4(network: string, prefixLength: number): AddressRange { - const parsed = parseCanonicalIpv4(network); - if (parsed === undefined) throw new Error("Invalid internal IPv4 range"); - return Object.freeze({ network: parsed, prefixLength }); -} - -function rangeFromIpv6(network: string, prefixLength: number): AddressRange { - const parsed = parseIpv6(network); - if (parsed === undefined) throw new Error("Invalid internal IPv6 range"); - return Object.freeze({ network: parsed, prefixLength }); -} - -const BLOCKED_IPV4 = IPV4_BLOCKED_RANGES.map(([network, prefixLength]) => - rangeFromIpv4(network, prefixLength), -); -const BLOCKED_IPV6 = IPV6_BLOCKED_RANGES.map(([network, prefixLength]) => - rangeFromIpv6(network, prefixLength), -); - -function isInRange(address: bigint, range: AddressRange, bitLength: number): boolean { - const trailingBits = BigInt(bitLength - range.prefixLength); - return address >> trailingBits === range.network >> trailingBits; -} - -function classifyIpv4(address: bigint): LiteralAddressClassification { - return BLOCKED_IPV4.some((range) => isInRange(address, range, IPV4_BIT_LENGTH)) - ? "non-public-or-special-literal" - : "ordinary-public-literal"; -} - -function classifyIpv6(address: bigint): LiteralAddressClassification { - // IPv4-mapped IPv6 addresses inherit the IPv4 classification. - if (address >> 32n === 0xffffn) return classifyIpv4(address & 0xffff_ffffn); - - if (BLOCKED_IPV6.some((range) => isInRange(address, range, IPV6_BIT_LENGTH))) { - return "non-public-or-special-literal"; - } - - // Only the globally allocated 2000::/3 space is accepted by default. Newly - // allocated special ranges outside it therefore fail closed until reviewed. - return address >> 125n === 1n ? "ordinary-public-literal" : "non-public-or-special-literal"; -} - -export function classifyResolvedAddress(address: unknown): LiteralAddressClassification { - if ( - typeof address !== "string" || - address.length === 0 || - address.length > MAX_RESOLVED_ADDRESS_TEXT_LENGTH || - address !== address.trim() - ) { - return "non-public-or-special-literal"; - } - - const ipv4 = parseCanonicalIpv4(address); - if (ipv4 !== undefined) return classifyIpv4(ipv4); - - const ipv6 = parseIpv6(address); - return ipv6 === undefined ? "non-public-or-special-literal" : classifyIpv6(ipv6); -} - -/** True only for the strict address spellings accepted from a resolver adapter. */ -export function isResolvedAddressLiteral(address: unknown): address is string { - if ( - typeof address !== "string" || - address.length === 0 || - address.length > MAX_RESOLVED_ADDRESS_TEXT_LENGTH || - address !== address.trim() - ) { - return false; - } - return parseCanonicalIpv4(address) !== undefined || parseIpv6(address) !== undefined; -} - -function canonicalResolvedAddress( - address: unknown, -): { readonly family: 4 | 6; readonly value: bigint } | undefined { - if ( - typeof address !== "string" || - address.length === 0 || - address.length > MAX_RESOLVED_ADDRESS_TEXT_LENGTH || - address !== address.trim() - ) { - return undefined; - } - const ipv4 = parseCanonicalIpv4(address); - if (ipv4 !== undefined) return { family: 4, value: ipv4 }; - const ipv6 = parseIpv6(address); - if (ipv6 === undefined) return undefined; - if (ipv6 >> 32n === 0xffffn) return { family: 4, value: ipv6 & 0xffff_ffffn }; - return { family: 6, value: ipv6 }; -} - -/** Compares strict resolver spellings, including IPv4-mapped socket addresses. */ -export function resolvedAddressesEqual(left: unknown, right: unknown): boolean { - const leftAddress = canonicalResolvedAddress(left); - const rightAddress = canonicalResolvedAddress(right); - return Boolean( - leftAddress && - rightAddress && - leftAddress.family === rightAddress.family && - leftAddress.value === rightAddress.value, - ); -} - -function containsUnsafeCodePoint(value: string): boolean { - for (let index = 0; index < value.length; index += 1) { - const codeUnit = value.charCodeAt(index); - if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { - const following = value.charCodeAt(index + 1); - if (following < 0xdc00 || following > 0xdfff) return true; - index += 1; - continue; - } - if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) return true; - if (codeUnit <= 0x20 || (codeUnit >= 0x7f && codeUnit <= 0x9f)) return true; - if ( - codeUnit === 0x061c || - codeUnit === 0x200b || - codeUnit === 0x200c || - codeUnit === 0x200d || - codeUnit === 0x200e || - codeUnit === 0x200f || - (codeUnit >= 0x2028 && codeUnit <= 0x202e) || - (codeUnit >= 0x2066 && codeUnit <= 0x2069) || - codeUnit === 0xfeff - ) { - return true; - } - } - return false; -} - -function classifyHostname(hostname: string): HostClassification { - const ipv4 = parseCanonicalIpv4(hostname); - if (ipv4 !== undefined) return classifyIpv4(ipv4); - - const ipv6 = parseIpv6(hostname); - if (ipv6 !== undefined) return classifyIpv6(ipv6); - - const labels = hostname.split("."); - if ( - labels.length === 1 || - labels.some((label) => label.length === 0 || label.length > 63) || - hostname.length > 253 || - hostname === "localhost" || - hostname.endsWith(".localhost") || - hostname === "local" || - hostname.endsWith(".local") || - hostname === "internal" || - hostname.endsWith(".internal") || - hostname === "home.arpa" || - hostname.endsWith(".home.arpa") - ) { - return "non-public-or-special-literal"; - } - - return "hostname-requires-resolution"; -} - -function normalizedHostFromUrl(url: URL): string | undefined { - let hostname = url.hostname.toLowerCase(); - if (hostname.startsWith("[") && hostname.endsWith("]")) { - hostname = hostname.slice(1, -1); - } else if (hostname.endsWith(".")) { - hostname = hostname.slice(0, -1); - if (hostname.endsWith(".")) return undefined; - } - return hostname.length === 0 ? undefined : hostname; -} - -function normalizedOrigin(protocol: NetworkProtocol, hostname: string, port: number): string { - const host = hostname.includes(":") ? `[${hostname}]` : hostname; - const defaultPort = protocol === "https:" ? 443 : 80; - return `${protocol}//${host}${port === defaultPort ? "" : `:${port}`}`; -} - -export function parseTargetPolicyView(value: unknown): TargetPolicyResult { - if (typeof value !== "string" || value.length === 0) { - return { ok: false, reason: "empty" }; - } - // Every UTF-8 encoding is at least as large as its UTF-16 code-unit count. - // Reject obvious oversize input before trim, scanning, or allocation. - if (value.length > MAX_NETWORK_TARGET_BYTES) return { ok: false, reason: "too-large" }; - if (value !== value.trim()) return { ok: false, reason: "empty" }; - if (containsUnsafeCodePoint(value)) return { ok: false, reason: "unsafe-unicode" }; - if (new TextEncoder().encode(value).byteLength > MAX_NETWORK_TARGET_BYTES) { - return { ok: false, reason: "too-large" }; - } - // WHATWG special-URL backslash normalization is not safe for a byte-exact - // capability target interpreted later by a different transport. - if (value.includes("\\")) return { ok: false, reason: "malformed" }; - const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase(); - if (scheme !== "http" && scheme !== "https") { - return { ok: false, reason: scheme ? "unsupported-scheme" : "malformed" }; - } - // Require an explicit authority spelling. WHATWG otherwise accepts and - // rewrites forms such as `http:example.com` and `http:///example.com`. - if (!/^https?:\/\/[^/?#]/i.test(value)) return { ok: false, reason: "malformed" }; - - let url: URL; - try { - url = new URL(value); - } catch { - return { ok: false, reason: "malformed" }; - } - - if (url.protocol !== "http:" && url.protocol !== "https:") { - return { ok: false, reason: "unsupported-scheme" }; - } - if (url.hash.length > 0) return { ok: false, reason: "malformed" }; - - const hostname = normalizedHostFromUrl(url); - if (!hostname) return { ok: false, reason: "malformed" }; - const protocol = url.protocol; - const authorityStart = value.indexOf("//") + 2; - const authorityEndCandidate = value.slice(authorityStart).search(/[/?#]/); - const authorityEnd = - authorityEndCandidate === -1 ? value.length : authorityStart + authorityEndCandidate; - const rawAuthority = value.slice(authorityStart, authorityEnd); - const port = url.port.length > 0 ? Number(url.port) : protocol === "https:" ? 443 : 80; - if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) { - return { ok: false, reason: "malformed" }; - } - - const classification = classifyHostname(hostname); - const result: TargetPolicyView = Object.freeze({ - protocol, - normalizedHostname: hostname, - normalizedOrigin: normalizedOrigin(protocol, hostname, port), - port, - hasCredentials: - rawAuthority.includes("@") || url.username.length > 0 || url.password.length > 0, - hostClassification: classification, - }); - return { ok: true, value: result }; -} diff --git a/src/parser/feedDocumentSource.test.ts b/src/parser/feedDocumentSource.test.ts new file mode 100644 index 0000000..1fa04cb --- /dev/null +++ b/src/parser/feedDocumentSource.test.ts @@ -0,0 +1,26 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchTextWithTimeout } from "src/utility/networkRequest"; +import { legacyObsidianFeedDocumentSource } from "./feedDocumentSource"; + +vi.mock("src/utility/networkRequest", () => ({ + fetchTextWithTimeout: vi.fn(), +})); + +const requestMock = vi.mocked(fetchTextWithTimeout); + +describe("legacyObsidianFeedDocumentSource", () => { + beforeEach(() => requestMock.mockReset()); + + it("loads feed text through the bounded shared request boundary", async () => { + requestMock.mockResolvedValue(""); + + await expect( + legacyObsidianFeedDocumentSource.load("https://feeds.example.com/show.xml"), + ).resolves.toBe(""); + expect(requestMock).toHaveBeenCalledWith("https://feeds.example.com/show.xml", { + timeoutMs: 30_000, + maxResponseBytes: 16 * 1024 * 1024, + acceptedStatuses: [200], + }); + }); +}); diff --git a/src/parser/feedDocumentSource.ts b/src/parser/feedDocumentSource.ts index 6ff56da..806e46a 100644 --- a/src/parser/feedDocumentSource.ts +++ b/src/parser/feedDocumentSource.ts @@ -1,4 +1,7 @@ -import { requestWithTimeout } from "src/utility/networkRequest"; +import { fetchTextWithTimeout } from "src/utility/networkRequest"; + +const FEED_REQUEST_TIMEOUT_MS = 30_000; +const MAX_FEED_DOCUMENT_BYTES = 16 * 1024 * 1024; /** Retrieval port for the legacy target-shaped feed parser. */ export interface FeedDocumentSource { @@ -14,7 +17,10 @@ export interface FeedDocumentSource { */ export const legacyObsidianFeedDocumentSource: FeedDocumentSource = Object.freeze({ async load(sourceUrl: string): Promise { - const response = await requestWithTimeout(sourceUrl, { timeoutMs: 30_000 }); - return response.text; + return fetchTextWithTimeout(sourceUrl, { + timeoutMs: FEED_REQUEST_TIMEOUT_MS, + maxResponseBytes: MAX_FEED_DOCUMENT_BYTES, + acceptedStatuses: [200], + }); }, }); diff --git a/src/parser/feedParser.test.ts b/src/parser/feedParser.test.ts index a0e77cb..c004777 100644 --- a/src/parser/feedParser.test.ts +++ b/src/parser/feedParser.test.ts @@ -4,23 +4,16 @@ import type { FeedDocumentSource } from "./feedDocumentSource"; import type { PodcastFeed } from "src/types/PodcastFeed"; vi.mock("src/utility/networkRequest", () => ({ - requestWithTimeout: vi.fn(), + fetchTextWithTimeout: vi.fn(), })); -import { requestWithTimeout } from "src/utility/networkRequest"; +import { fetchTextWithTimeout } from "src/utility/networkRequest"; -const mockRequestWithTimeout = vi.mocked(requestWithTimeout); +const mockRequestWithTimeout = vi.mocked(fetchTextWithTimeout); -// Build the shape requestWithTimeout resolves to. Keeps the per-test mock setup -// to a single line and makes the number of expected fetches obvious at a glance. -function feedResponse(text: string) { - return { - text, - status: 200, - headers: {}, - arrayBuffer: new ArrayBuffer(0), - json: {}, - }; +// Keep per-test mock setup to one line and make the expected fetch count obvious. +function feedResponse(text: string): string { + return text; } const sampleRssFeed = ` @@ -215,13 +208,7 @@ describe("FeedParser", () => { describe("getFeed", () => { test("parses feed title and URL correctly", async () => { - mockRequestWithTimeout.mockResolvedValueOnce({ - text: sampleRssFeed, - status: 200, - headers: {}, - arrayBuffer: new ArrayBuffer(0), - json: {}, - }); + mockRequestWithTimeout.mockResolvedValueOnce(feedResponse(sampleRssFeed)); const parser = new FeedParser(); const feed = await parser.getFeed("https://example.com/feed.xml"); @@ -229,18 +216,14 @@ describe("FeedParser", () => { expect(feed.title).toBe("Test Podcast"); expect(feed.url).toBe("https://example.com/feed.xml"); expect(mockRequestWithTimeout).toHaveBeenCalledWith("https://example.com/feed.xml", { + acceptedStatuses: [200], + maxResponseBytes: 16 * 1024 * 1024, timeoutMs: 30_000, }); }); test("parses artwork URL from image element", async () => { - mockRequestWithTimeout.mockResolvedValueOnce({ - text: sampleRssFeed, - status: 200, - headers: {}, - arrayBuffer: new ArrayBuffer(0), - json: {}, - }); + mockRequestWithTimeout.mockResolvedValueOnce(feedResponse(sampleRssFeed)); const parser = new FeedParser(); const feed = await parser.getFeed("https://example.com/feed.xml"); @@ -249,13 +232,9 @@ describe("FeedParser", () => { }); test("parses artwork URL from itunes:image href attribute", async () => { - mockRequestWithTimeout.mockResolvedValueOnce({ - text: sampleRssFeedWithItunesImage, - status: 200, - headers: {}, - arrayBuffer: new ArrayBuffer(0), - json: {}, - }); + mockRequestWithTimeout.mockResolvedValueOnce( + feedResponse(sampleRssFeedWithItunesImage), + ); const parser = new FeedParser(); const feed = await parser.getFeed("https://example.com/feed.xml"); @@ -277,13 +256,7 @@ describe("FeedParser", () => { `; - mockRequestWithTimeout.mockResolvedValueOnce({ - text: feedWithOnlyItemImage, - status: 200, - headers: {}, - arrayBuffer: new ArrayBuffer(0), - json: {}, - }); + mockRequestWithTimeout.mockResolvedValueOnce(feedResponse(feedWithOnlyItemImage)); const parser = new FeedParser(); const feed = await parser.getFeed("https://example.com/feed.xml"); @@ -293,13 +266,7 @@ describe("FeedParser", () => { }); test("throws error for invalid RSS feed without title", async () => { - mockRequestWithTimeout.mockResolvedValueOnce({ - text: invalidRssFeed, - status: 200, - headers: {}, - arrayBuffer: new ArrayBuffer(0), - json: {}, - }); + mockRequestWithTimeout.mockResolvedValueOnce(feedResponse(invalidRssFeed)); const parser = new FeedParser(); @@ -309,13 +276,9 @@ describe("FeedParser", () => { }); test("captures channel link, description and author, skipping atom:link self-refs", async () => { - mockRequestWithTimeout.mockResolvedValueOnce({ - text: sampleRssFeedWithAtomAndMetadata, - status: 200, - headers: {}, - arrayBuffer: new ArrayBuffer(0), - json: {}, - }); + mockRequestWithTimeout.mockResolvedValueOnce( + feedResponse(sampleRssFeedWithAtomAndMetadata), + ); const parser = new FeedParser(); const feed = await parser.getFeed("https://example.com/feed.xml"); @@ -327,13 +290,7 @@ describe("FeedParser", () => { }); test("does not throw when the channel has no website link", async () => { - mockRequestWithTimeout.mockResolvedValueOnce({ - text: rssFeedWithoutChannelLink, - status: 200, - headers: {}, - arrayBuffer: new ArrayBuffer(0), - json: {}, - }); + mockRequestWithTimeout.mockResolvedValueOnce(feedResponse(rssFeedWithoutChannelLink)); const parser = new FeedParser(); const feed = await parser.getFeed("https://example.com/feed.xml"); @@ -343,13 +300,9 @@ describe("FeedParser", () => { }); test("uses the atom alternate link, never a hub/self link, for the website", async () => { - mockRequestWithTimeout.mockResolvedValueOnce({ - text: rssFeedWithAtomHubAndAlternate, - status: 200, - headers: {}, - arrayBuffer: new ArrayBuffer(0), - json: {}, - }); + mockRequestWithTimeout.mockResolvedValueOnce( + feedResponse(rssFeedWithAtomHubAndAlternate), + ); const parser = new FeedParser(); const feed = await parser.getFeed("https://example.com/feed.xml"); @@ -358,13 +311,7 @@ describe("FeedParser", () => { }); test("honours tag priority: itunes:author over managingEditor, itunes:summary when description is empty", async () => { - mockRequestWithTimeout.mockResolvedValueOnce({ - text: rssFeedWithMetadataPriority, - status: 200, - headers: {}, - arrayBuffer: new ArrayBuffer(0), - json: {}, - }); + mockRequestWithTimeout.mockResolvedValueOnce(feedResponse(rssFeedWithMetadataPriority)); const parser = new FeedParser(); const feed = await parser.getFeed("https://example.com/feed.xml"); diff --git a/src/services/diarization/deepgramProvider.test.ts b/src/services/diarization/deepgramProvider.test.ts index 06bcc84..54e572e 100644 --- a/src/services/diarization/deepgramProvider.test.ts +++ b/src/services/diarization/deepgramProvider.test.ts @@ -1,6 +1,21 @@ -import { describe, expect, it, vi } from "vitest"; -import { diarizeWithDeepgram, type RequestUrlFn } from "./deepgramProvider"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RequestUrlResponse } from "obsidian"; + +const nativeRequest = vi.hoisted(() => vi.fn()); + +vi.mock("../../utility/networkRequest", async () => { + const actual = await vi.importActual( + "../../utility/networkRequest", + ); + const fetchJsonWithTimeout: typeof actual.fetchJsonWithTimeout = (url, options) => + actual.fetchJsonWithTimeout(url, { ...options, request: nativeRequest }); + + return { ...actual, fetchJsonWithTimeout: vi.fn(fetchJsonWithTimeout) }; +}); + +import { diarizeWithDeepgram } from "./deepgramProvider"; import type { DiarizationAudio } from "./types"; +import { NetworkError, fetchJsonWithTimeout } from "../../utility/networkRequest"; const audio: DiarizationAudio = { buffer: new ArrayBuffer(8), @@ -10,14 +25,15 @@ const audio: DiarizationAudio = { }; function stubResponse(overrides: Partial<{ status: number; json: unknown; text: string }>) { + const json = overrides.json ?? {}; return { status: 200, - json: {}, - text: "", + json, + text: overrides.text ?? JSON.stringify(json), arrayBuffer: new ArrayBuffer(0), headers: {}, ...overrides, - } as unknown as Awaited>; + } satisfies RequestUrlResponse; } function deferred() { @@ -43,9 +59,14 @@ async function settlesAfterMicrotasks(promise: Promise): Promise { + nativeRequest.mockReset(); + vi.mocked(fetchJsonWithTimeout).mockClear(); +}); + describe("diarizeWithDeepgram (#168)", () => { it("posts the whole audio buffer with token auth and parses the response", async () => { - const request = vi.fn().mockResolvedValue( + nativeRequest.mockResolvedValue( stubResponse({ json: { results: { @@ -62,7 +83,6 @@ describe("diarizeWithDeepgram (#168)", () => { audio, apiKey: "dg-key", onProgress: () => {}, - request, }); expect(segments).toEqual([ @@ -70,7 +90,21 @@ describe("diarizeWithDeepgram (#168)", () => { { speaker: "2", text: "Hi.", start: 1, end: 2 }, ]); - const call = request.mock.calls[0][0]; + expect(fetchJsonWithTimeout).toHaveBeenCalledWith( + expect.stringContaining("api.deepgram.com/v1/listen"), + { + method: "POST", + headers: { Authorization: "Token dg-key" }, + contentType: "audio/mpeg", + body: audio.buffer, + timeoutMs: 30 * 60_000, + maxRequestBodyBytes: 2 * 1024 * 1024 * 1024, + maxResponseBytes: 16 * 1024 * 1024, + signal: undefined, + }, + ); + + const call = nativeRequest.mock.calls[0][0]; expect(call.method).toBe("POST"); expect(call.url).toContain("api.deepgram.com/v1/listen"); expect(call.url).toContain("diarize=true"); @@ -79,41 +113,56 @@ describe("diarizeWithDeepgram (#168)", () => { expect(call.body).toBe(audio.buffer); }); - it("throws a helpful error on a non-2xx response", async () => { - const request = vi - .fn() - .mockResolvedValue( - stubResponse({ status: 401, json: { err_msg: "Invalid credentials" } }), - ); - - await expect( - diarizeWithDeepgram({ - audio, - apiKey: "bad", - onProgress: () => {}, - request, - }), - ).rejects.toThrow(/HTTP 401.*Invalid credentials/); + it("reports only a safe HTTP status on a non-2xx response", async () => { + nativeRequest.mockResolvedValue( + stubResponse({ status: 401, json: { err_msg: "Invalid credentials" } }), + ); + + const error = await diarizeWithDeepgram({ + audio, + apiKey: "bad", + onProgress: () => {}, + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(NetworkError); + expect((error as NetworkError).status).toBe(401); + expect(String(error)).toContain("HTTP 401"); + expect(String(error)).not.toContain("Invalid credentials"); + }); + + it("redacts the API key and a native transport error", async () => { + const marker = "private-deepgram-marker"; + nativeRequest.mockRejectedValue(new Error(`native failure with ${marker}`)); + + const error = await diarizeWithDeepgram({ + audio, + apiKey: marker, + onProgress: () => {}, + }).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(NetworkError); + expect((error as NetworkError).code).toBe("transport-failure"); + expect(String(error)).not.toContain(marker); + expect(String(error)).not.toContain("native failure"); }); - it("aborts promptly and ignores a late HTTP failure from requestUrl", async () => { + it("aborts promptly and ignores a late HTTP failure from the native transport", async () => { const controller = new AbortController(); - const abortError = new DOMException("plugin unloaded", "AbortError"); - const response = deferred>>(); - const request = vi.fn().mockReturnValue(response.promise); + const abortError = new DOMException("private lifecycle detail", "AbortError"); + const response = deferred(); + nativeRequest.mockReturnValue(response.promise); const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); const pending = diarizeWithDeepgram({ audio, apiKey: "bad", onProgress: () => {}, - request, signal: controller.signal, }); const outcome = pending.then( () => undefined, (error: unknown) => error, ); - await vi.waitFor(() => expect(request).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(nativeRequest).toHaveBeenCalledOnce()); try { controller.abort(abortError); @@ -122,7 +171,9 @@ describe("diarizeWithDeepgram (#168)", () => { const result = await outcome; expect(settledPromptly).toBe(true); - expect(result).toBe(abortError); + expect(result).toBeInstanceOf(NetworkError); + expect((result as NetworkError).code).toBe("aborted"); + expect(String(result)).not.toContain(abortError.message); expect(consoleError).not.toHaveBeenCalled(); } finally { consoleError.mockRestore(); diff --git a/src/services/diarization/deepgramProvider.ts b/src/services/diarization/deepgramProvider.ts index e7cf5de..a99741a 100644 --- a/src/services/diarization/deepgramProvider.ts +++ b/src/services/diarization/deepgramProvider.ts @@ -1,18 +1,11 @@ -import { requestUrl, type RequestUrlResponse } from "obsidian"; import type { DiarizationAudio, DiarizedSegment } from "./types"; import { parseDeepgramSegments } from "./segments"; - -/** The injectable shape of Obsidian's `requestUrl` (so tests can stub the network). */ -export type RequestUrlFn = (params: { - url: string; - method: string; - headers: Record; - contentType: string; - body: ArrayBuffer; - throw: boolean; -}) => Promise; +import { fetchJsonWithTimeout } from "../../utility/networkRequest"; const DEEPGRAM_LISTEN_URL = "https://api.deepgram.com/v1/listen"; +const DEEPGRAM_REQUEST_TIMEOUT_MS = 30 * 60_000; +const MAX_DEEPGRAM_AUDIO_BYTES = 2 * 1024 * 1024 * 1024; +const MAX_DEEPGRAM_RESPONSE_BYTES = 16 * 1024 * 1024; /** * Query params for the pre-recorded request. `diarize=true` is the broadly @@ -27,103 +20,32 @@ const DEEPGRAM_QUERY = "model=nova-3&diarize=true&punctuate=true&smart_format=tr * Diarize with Deepgram's pre-recorded speech-to-text API (issue #168). * * Posts the whole original (compressed) audio in one synchronous request via - * Obsidian's `requestUrl` (which bypasses CORS and works on mobile). No chunking - * is needed — Deepgram accepts large files server-side — which is exactly why - * its speaker labels stay consistent for the full episode. + * the shared bounded network boundary. No chunking is needed because Deepgram + * accepts large files server-side, which keeps speaker labels consistent for + * the full episode. */ export async function diarizeWithDeepgram(opts: { audio: DiarizationAudio; apiKey: string; onProgress: (message: string) => void; - request?: RequestUrlFn; signal?: AbortSignal; }): Promise { const { audio, apiKey, onProgress, signal } = opts; - const request = opts.request ?? (requestUrl as unknown as RequestUrlFn); - - if (signal) throwIfAborted(signal); onProgress("Diarizing with Deepgram..."); - const requestPromise = request({ - url: `${DEEPGRAM_LISTEN_URL}?${DEEPGRAM_QUERY}`, - method: "POST", - headers: { Authorization: `Token ${apiKey}` }, - contentType: audio.mimeType, - body: audio.buffer, - throw: false, - }); - const response = signal ? await waitForAbort(requestPromise, signal) : await requestPromise; - - if (response.status < 200 || response.status >= 300) { - const detail = extractDeepgramError(response); - console.error("Deepgram diarization request failed:", response.status, detail); - throw new Error( - `Deepgram request failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`, - ); - } - - return parseDeepgramSegments(response.json); -} - -function throwIfAborted(signal: AbortSignal): void { - if (!signal.aborted) return; - throw signal.reason ?? new DOMException("Deepgram diarization was aborted.", "AbortError"); -} - -function waitForAbort(operation: Promise, signal: AbortSignal): Promise { - throwIfAborted(signal); - - return new Promise((resolve, reject) => { - let settled = false; - const cleanup = () => signal.removeEventListener("abort", onAbort); - const onAbort = () => { - if (settled) return; - settled = true; - cleanup(); - reject( - signal.reason ?? - new DOMException("Deepgram diarization was aborted.", "AbortError"), - ); - }; - - signal.addEventListener("abort", onAbort, { once: true }); - void operation.then( - (value) => { - if (settled) return; - if (signal.aborted) { - onAbort(); - return; - } - settled = true; - cleanup(); - resolve(value); - }, - (error) => { - if (settled) return; - if (signal.aborted) { - onAbort(); - return; - } - settled = true; - cleanup(); - reject(error); - }, - ); - if (signal.aborted) onAbort(); - }); -} - -/** Pull a human-readable message out of Deepgram's error body, if any. */ -function extractDeepgramError(response: RequestUrlResponse): string { - try { - const body = response.json as unknown; - if (body && typeof body === "object") { - const record = body as Record; - const message = record.err_msg ?? record.message ?? record.reason; - if (typeof message === "string") return message; - } - } catch { - // Non-JSON error body; fall back to the raw text below. - } - return typeof response.text === "string" ? response.text.slice(0, 200) : ""; + const response = await fetchJsonWithTimeout( + `${DEEPGRAM_LISTEN_URL}?${DEEPGRAM_QUERY}`, + { + method: "POST", + headers: { Authorization: `Token ${apiKey}` }, + contentType: audio.mimeType, + body: audio.buffer, + timeoutMs: DEEPGRAM_REQUEST_TIMEOUT_MS, + maxRequestBodyBytes: MAX_DEEPGRAM_AUDIO_BYTES, + maxResponseBytes: MAX_DEEPGRAM_RESPONSE_BYTES, + signal, + }, + ); + + return parseDeepgramSegments(response); } diff --git a/src/services/diarization/index.ts b/src/services/diarization/index.ts index a09453d..34711bd 100644 --- a/src/services/diarization/index.ts +++ b/src/services/diarization/index.ts @@ -4,7 +4,7 @@ import type { CredentialKind } from "src/types/Credentials"; export * from "./types"; export * from "./segments"; export { diarizeWithOpenAI } from "./openaiProvider"; -export { diarizeWithDeepgram, type RequestUrlFn } from "./deepgramProvider"; +export { diarizeWithDeepgram } from "./deepgramProvider"; /** * Whether the credentials the active transcription mode needs are present. diff --git a/src/utility/assertFetchableUrl.test.ts b/src/utility/assertFetchableUrl.test.ts index 9bb7450..9f80cb4 100644 --- a/src/utility/assertFetchableUrl.test.ts +++ b/src/utility/assertFetchableUrl.test.ts @@ -1,19 +1,109 @@ import { describe, expect, it } from "vitest"; -import { assertFetchableUrl, isFetchableUrl, UnsafeFetchUrlError } from "./assertFetchableUrl"; +import { + MAX_NETWORK_TARGET_BYTES, + UnsafeFetchUrlError, + assertFetchableUrl, + isFetchableUrl, + type UnsafeFetchUrlReason, +} from "./assertFetchableUrl"; + +function expectRejection(target: string, reason: UnsafeFetchUrlReason): void { + expect(() => assertFetchableUrl(target)).toThrowError( + expect.objectContaining({ name: "UnsafeFetchUrlError", reason }), + ); +} describe("assertFetchableUrl", () => { - it("accepts ordinary public http(s) feed/enclosure URLs", () => { + it("accepts ordinary public HTTP(S) feed and enclosure URLs", () => { expect(() => assertFetchableUrl("https://pod.example.com/audio.mp3?token=abc"), ).not.toThrow(); expect(() => assertFetchableUrl("http://cdn.example.org/ep/1.mp3")).not.toThrow(); - // A public IP is fine. expect(() => assertFetchableUrl("https://8.8.8.8/feed.xml")).not.toThrow(); }); - it("returns the parsed URL", () => { - const url = assertFetchableUrl("https://example.com/a.mp3"); - expect(url.hostname).toBe("example.com"); + it("returns the complete parsed target, including credentials", () => { + const url = assertFetchableUrl( + "HTTPS://User:Pass@ExAmPle.COM.:443/a%2Fb?sig=A%2BB&token=secret", + ); + + expect(url.href).toBe("https://User:Pass@example.com./a%2Fb?sig=A%2BB&token=secret"); + expect(url.username).toBe("User"); + expect(url.password).toBe("Pass"); + }); + + it("canonicalizes and encodes accepted internal ASCII spaces and parentheses", () => { + const url = assertFetchableUrl( + "https://example.com/podcast/Episode (Part 1).mp3?token=(abc)", + ); + + expect(url.href).toBe( + "https://example.com/podcast/Episode%20%28Part%201%29.mp3?token=%28abc%29", + ); + }); + + it.each([ + ["file://user:secret@example.com/private?token=secret", "unsupported-scheme"], + ["https://user:secret@localhost/private?token=secret", "non-public-or-special-host"], + ["https://user:secret@example.com/#token=secret", "malformed"], + ] as const)("returns only a stable redacted error for %s", (target, reason) => { + let thrown: unknown; + try { + assertFetchableUrl(target); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(UnsafeFetchUrlError); + expect(thrown).toMatchObject({ name: "UnsafeFetchUrlError", reason }); + const error = thrown as UnsafeFetchUrlError; + expect(error.message).not.toContain(target); + expect(error.message).not.toContain("secret"); + expect(JSON.stringify({ message: error.message, reason: error.reason })).not.toContain( + "secret", + ); + }); + + it.each(["", " ", " https://example.com/feed", "https://example.com/feed "])( + "rejects empty or leading/trailing-whitespace input without normalization: %s", + (target) => { + expectRejection(target, "empty"); + }, + ); + + it.each([ + [0x00, "NULL"], + [0x09, "TAB"], + [0x0a, "LF"], + [0x1f, "C0 control"], + [0x7f, "DELETE"], + [0x85, "C1 control"], + [0x061c, "Arabic letter mark"], + [0x200b, "zero-width space"], + [0x200c, "zero-width non-joiner"], + [0x200d, "zero-width joiner"], + [0x200e, "left-to-right mark"], + [0x200f, "right-to-left mark"], + [0x2028, "line separator"], + [0x2029, "paragraph separator"], + [0x202a, "left-to-right embedding"], + [0x202b, "right-to-left embedding"], + [0x202c, "pop directional formatting"], + [0x202d, "left-to-right override"], + [0x202e, "right-to-left override"], + [0x2066, "left-to-right isolate"], + [0x2067, "right-to-left isolate"], + [0x2068, "first strong isolate"], + [0x2069, "pop directional isolate"], + [0xfeff, "byte-order mark"], + ] as const)("rejects internal code point %s", (codePoint, _name) => { + const target = `https://example.com/a${String.fromCodePoint(codePoint)}b`; + expectRejection(target, "unsafe-unicode"); + }); + + it("rejects unpaired UTF-16 surrogates", () => { + expectRejection(`https://example.com/a${String.fromCharCode(0xd800)}b`, "unsafe-unicode"); + expectRejection(`https://example.com/a${String.fromCharCode(0xdc00)}b`, "unsafe-unicode"); }); it.each([ @@ -22,68 +112,138 @@ describe("assertFetchableUrl", () => { "blob:https://example.com/uuid", "ftp://example.com/file", "javascript:alert(1)", - ])("rejects non-http(s) scheme %s", (url) => { - expect(() => assertFetchableUrl(url)).toThrow(UnsafeFetchUrlError); + ])("rejects a non-HTTP(S) scheme: %s", (url) => { + expectRejection(url, "unsupported-scheme"); }); - it.each(["", " ", "not a url", "//example.com/no-scheme"])( - "rejects empty/malformed url %s", - (url) => { - expect(() => assertFetchableUrl(url)).toThrow(UnsafeFetchUrlError); - }, - ); + it.each([ + "not a target", + "//example.com/no-scheme", + "https://example.com/#fragment", + "https:\\example.com\\feed", + "http:example.com/feed", + "http:/example.com/feed", + "http:///example.com/feed", + "https:////example.com/feed", + "https://", + ])("rejects malformed URL or authority syntax: %s", (url) => { + expectRejection(url, "malformed"); + }); it.each([ "http://localhost/feed.xml", "http://LOCALHOST:8080/x", "http://api.localhost/x", - "http://localhost./x", // absolute-FQDN form still resolves to loopback + "http://localhost./x", "http://LOCALHOST./x", "http://sub.localhost./x", + "http://printer.local/x", + "http://service.internal/x", + "http://router.home.arpa/x", + "http://single-label/x", + ])("blocks a locally scoped hostname: %s", (url) => { + expectRejection(url, "non-public-or-special-host"); + }); + + it.each([ + "http://0.0.0.0/x", + "http://10.0.0.1/x", + "http://100.64.0.1/x", "http://127.0.0.1/x", - "http://127.1.2.3/x", - "http://10.0.0.5/x", - "http://172.16.0.1/x", + "http://169.254.169.254/latest/meta-data/", "http://172.31.255.255/x", - "http://192.168.1.5/x", - "http://169.254.169.254/latest/meta-data/", // cloud metadata - "http://0.0.0.0/x", - ])("blocks loopback/private/link-local host %s", (url) => { - expect(() => assertFetchableUrl(url)).toThrow(UnsafeFetchUrlError); + "http://192.0.0.9/x", + "http://192.0.2.1/x", + "http://192.31.196.1/x", + "http://192.52.193.1/x", + "http://192.88.99.1/x", + "http://192.168.1.1/x", + "http://192.175.48.1/x", + "http://198.18.0.1/x", + "http://198.51.100.1/x", + "http://203.0.113.1/x", + "http://224.0.0.1/x", + "http://255.255.255.255/x", + ])("blocks a non-public or special IPv4 host: %s", (url) => { + expectRejection(url, "non-public-or-special-host"); }); it.each([ - "http://2130706433/x", // 127.0.0.1 as integer - "http://0x7f000001/x", // 127.0.0.1 as hex - "http://0177.0.0.1/x", // 127.0.0.1 with octal leading octet - "http://0/x", // 0.0.0.0 - ])("blocks obfuscated IPv4 loopback %s", (url) => { - expect(() => assertFetchableUrl(url)).toThrow(UnsafeFetchUrlError); + "http://2130706433/x", + "http://0x7f000001/x", + "http://0177.0.0.1/x", + "http://192.168.1/x", + "http://0/x", + ])("blocks an obfuscated non-public IPv4 host: %s", (url) => { + expectRejection(url, "non-public-or-special-host"); }); it.each([ - "http://[::1]/x", // loopback - "http://[::]/x", // unspecified - "http://[fe80::1]/x", // link-local - "http://[fc00::1]/x", // unique-local - "http://[fd12:3456:789a::1]/x", // unique-local - "http://[::ffff:127.0.0.1]/x", // IPv4-mapped loopback - "http://[::ffff:169.254.169.254]/x", // IPv4-mapped metadata - "http://[::ffff:7f00:1]/x", // IPv4-mapped loopback in hex form - ])("blocks loopback/private IPv6 host %s", (url) => { - expect(() => assertFetchableUrl(url)).toThrow(UnsafeFetchUrlError); + "http://[::]/x", + "http://[::1]/x", + "http://[64:ff9b::c000:201]/x", + "http://[64:ff9b:1::1]/x", + "http://[100::1]/x", + "http://[100:0:0:1::1]/x", + "http://[2001::1]/x", + "http://[2001:db8::1]/x", + "http://[2002::1]/x", + "http://[2620:4f:8000::1]/x", + "http://[3fff::1]/x", + "http://[5f00::1]/x", + "http://[fc00::1]/x", + "http://[fd12:3456:789a::1]/x", + "http://[fe80::1]/x", + "http://[fec0::1]/x", + "http://[ff02::1]/x", + "http://[4000::1]/x", + "http://[::ffff:127.0.0.1]/x", + "http://[::ffff:169.254.169.254]/x", + "http://[::ffff:7f00:1]/x", + ])("blocks a special or non-global IPv6 host: %s", (url) => { + expectRejection(url, "non-public-or-special-host"); }); it.each([ - "https://[2606:4700:4700::1111]/x", // public IPv6 (Cloudflare DNS) - "https://203.0.113.10/x", // public IPv4 - "https://pod.example.com./feed.xml", // legit absolute FQDN is not blocked - ])("allows public IPv6/IPv4/FQDN host %s", (url) => { + "https://1.1.1.1/x", + "https://8.8.8.8/x", + "https://93.184.216.34/x", + "https://223.255.255.254/x", + "https://[2001:4860:4860::8888]/x", + "https://[2606:4700:4700::1111]/x", + "https://[::ffff:8.8.8.8]/x", + "https://pod.example.com./feed.xml", + ])("allows an ordinary public address or hostname: %s", (url) => { expect(() => assertFetchableUrl(url)).not.toThrow(); }); + it.each(["https://@example.com/feed", "https://:@example.com/feed"])( + "accepts even empty credential syntax for private-feed compatibility: %s", + (target) => { + expect(() => assertFetchableUrl(target)).not.toThrow(); + }, + ); + + it("bounds both raw and canonical encoded targets by UTF-8 bytes", () => { + const prefix = "https://example.com/"; + const oversized = `${prefix}${"x".repeat(MAX_NETWORK_TARGET_BYTES - prefix.length + 1)}`; + expectRejection(oversized, "too-large"); + + const multibyte = `${prefix}${"é".repeat(MAX_NETWORK_TARGET_BYTES / 2)}`; + expect(multibyte.length).toBeLessThan(MAX_NETWORK_TARGET_BYTES); + expectRejection(multibyte, "too-large"); + + const expandsPastLimit = `${prefix}${" ".repeat( + MAX_NETWORK_TARGET_BYTES - prefix.length - 1, + )}x`; + expect(new TextEncoder().encode(expandsPastLimit).byteLength).toBe( + MAX_NETWORK_TARGET_BYTES, + ); + expectRejection(expandsPastLimit, "too-large"); + }); + it("exposes a non-throwing predicate", () => { - expect(isFetchableUrl("https://example.com/a.mp3")).toBe(true); + expect(isFetchableUrl("https://example.com/Episode (Part 1).mp3")).toBe(true); expect(isFetchableUrl("http://169.254.169.254/")).toBe(false); expect(isFetchableUrl("file:///etc/passwd")).toBe(false); }); diff --git a/src/utility/assertFetchableUrl.ts b/src/utility/assertFetchableUrl.ts index 8effcf0..0faae7a 100644 --- a/src/utility/assertFetchableUrl.ts +++ b/src/utility/assertFetchableUrl.ts @@ -1,189 +1,351 @@ -/** - * SSRF guard for every fetch whose URL comes from untrusted feed/URI content. - * - * Podcast enclosure URLs (``) and `obsidian://podnotes` - * deep-link URLs are fully attacker-controlled, yet they flow straight into - * Obsidian's `requestUrl`. Before issuing any such request we: - * - require an http(s) scheme, so a feed can't make the client read a local - * file (`file:`), inline a payload (`data:`/`blob:`), or hit a non-HTTP - * service (`ftp:`, ...); and - * - reject hosts that resolve to loopback / link-local / private / cloud-metadata - * ranges, so a feed can't turn the client into a blind SSRF proxy against - * 127.0.0.1, 169.254.169.254, or the user's intranet. - * - * This applies the same http(s)-only check the project already uses for the - * chapters URL (`isSupportedChaptersUrl` in fetchChapters.ts) and for feed-add - * (`PodcastQueryGrid.svelte`), and additionally filters the host, for the - * download/feed/URI fetch paths. - * - * Limitations (both inherent to validating a URL string ahead of an opaque - * `requestUrl`): - * - DNS rebinding: a public hostname that resolves to a private/loopback IP is - * allowed, because this checks the literal host, not the resolved address. - * - Redirects: `requestUrl` follows them, and an allowed http(s) host could 302 - * into a blocked range, which cannot be re-checked per hop here. - * The scheme allowlist (the `file:`/`data:` local-read/exfil vector) is unaffected - * by both. - */ -export class UnsafeFetchUrlError extends Error { - constructor(message: string) { - super(message); - this.name = "UnsafeFetchUrlError"; - } +import { encodeUrlForRequest } from "./encodeUrlForRequest"; + +/** Maximum UTF-8 size accepted for both raw and encoded network targets. */ +export const MAX_NETWORK_TARGET_BYTES = 16 * 1024; + +type HostClassification = + | "ordinary-public-literal" + | "non-public-or-special-literal" + | "hostname-requires-resolution"; + +type LiteralAddressClassification = Exclude; + +type TargetPolicyFailureReason = + | "empty" + | "too-large" + | "malformed" + | "unsupported-scheme" + | "unsafe-unicode"; + +export type UnsafeFetchUrlReason = TargetPolicyFailureReason | "non-public-or-special-host"; + +interface AddressRange { + readonly network: bigint; + readonly prefixLength: number; } -/** - * Validates that `rawUrl` is safe to fetch and returns the parsed URL. - * Throws {@link UnsafeFetchUrlError} for an unparseable URL, a non-http(s) - * scheme, or a host in a blocked (loopback/link-local/private/metadata) range. - */ -export function assertFetchableUrl(rawUrl: string): URL { - const trimmed = rawUrl?.trim() ?? ""; - if (!trimmed) { - throw new UnsafeFetchUrlError("Refusing to fetch an empty URL."); - } +interface ParsedTargetPolicy { + readonly url: URL; + readonly hostClassification: HostClassification; +} - let url: URL; - try { - url = new URL(trimmed); - } catch { - throw new UnsafeFetchUrlError(`Refusing to fetch a malformed URL: ${rawUrl}`); +type TargetPolicyResult = + | { readonly ok: true; readonly value: ParsedTargetPolicy } + | { readonly ok: false; readonly reason: TargetPolicyFailureReason }; + +const IPV4_BIT_LENGTH = 32; +const IPV6_BIT_LENGTH = 128; + +const IPV4_BLOCKED_RANGES = [ + ["0.0.0.0", 8], + ["10.0.0.0", 8], + ["100.64.0.0", 10], + ["127.0.0.0", 8], + ["169.254.0.0", 16], + ["172.16.0.0", 12], + ["192.0.0.0", 24], + ["192.0.2.0", 24], + ["192.31.196.0", 24], + ["192.52.193.0", 24], + ["192.88.99.0", 24], + ["192.168.0.0", 16], + ["192.175.48.0", 24], + ["198.18.0.0", 15], + ["198.51.100.0", 24], + ["203.0.113.0", 24], + ["224.0.0.0", 4], + ["240.0.0.0", 4], +] as const; + +const IPV6_BLOCKED_RANGES = [ + ["::", 128], + ["::1", 128], + ["64:ff9b::", 96], + ["64:ff9b:1::", 48], + ["100::", 64], + ["100:0:0:1::", 64], + ["2001::", 23], + ["2001:db8::", 32], + ["2002::", 16], + ["2620:4f:8000::", 48], + ["3fff::", 20], + ["5f00::", 16], + ["fc00::", 7], + ["fe80::", 10], + ["fec0::", 10], + ["ff00::", 8], +] as const; + +function parseCanonicalIpv4(value: string): bigint | undefined { + const parts = value.split("."); + if (parts.length !== 4) return undefined; + + let address = 0n; + for (const part of parts) { + if (!/^(?:0|[1-9][0-9]{0,2})$/.test(part)) return undefined; + const octet = Number(part); + if (octet > 255) return undefined; + address = (address << 8n) | BigInt(octet); } + return address; +} - if (url.protocol !== "http:" && url.protocol !== "https:") { - throw new UnsafeFetchUrlError( - `Refusing to fetch a non-http(s) URL (${url.protocol}): ${rawUrl}`, - ); +function parseIpv6(value: string): bigint | undefined { + const candidate = value.toLowerCase(); + if (candidate.length === 0 || candidate.includes("%")) return undefined; + + const compressionIndex = candidate.indexOf("::"); + if (compressionIndex !== -1 && candidate.indexOf("::", compressionIndex + 2) !== -1) { + return undefined; } - if (isBlockedHost(url.hostname)) { - throw new UnsafeFetchUrlError( - `Refusing to fetch a loopback/private/link-local address: ${url.hostname}`, + const parseSide = (side: string): string[] | undefined => { + if (side.length === 0) return []; + const pieces = side.split(":"); + if (pieces.some((piece) => piece.length === 0)) return undefined; + return pieces; + }; + + const left = parseSide( + compressionIndex === -1 ? candidate : candidate.slice(0, compressionIndex), + ); + const right = parseSide(compressionIndex === -1 ? "" : candidate.slice(compressionIndex + 2)); + if (!left || !right) return undefined; + + const sideWithLastPiece = right.length > 0 ? right : left; + const lastPiece = sideWithLastPiece[sideWithLastPiece.length - 1]; + if (lastPiece?.includes(".")) { + // An embedded dotted quad is the final 32 bits of an IPv6 spelling. It + // cannot precede a trailing compression marker such as 192.0.2.1::. + if (compressionIndex !== -1 && right.length === 0) return undefined; + const ipv4 = parseCanonicalIpv4(lastPiece); + if (ipv4 === undefined) return undefined; + sideWithLastPiece.splice( + sideWithLastPiece.length - 1, + 1, + Number((ipv4 >> 16n) & 0xffffn).toString(16), + Number(ipv4 & 0xffffn).toString(16), ); } - return url; + const combined = [...left, ...right]; + if (combined.some((piece) => piece.includes("."))) return undefined; + if (combined.some((piece) => !/^[0-9a-f]{1,4}$/.test(piece))) return undefined; + if (compressionIndex === -1 && combined.length !== 8) return undefined; + if (compressionIndex !== -1 && combined.length >= 8) return undefined; + + const missing = 8 - combined.length; + const hextets = + compressionIndex === -1 + ? combined + : [...left, ...Array.from({ length: missing }, () => "0"), ...right]; + if (hextets.length !== 8) return undefined; + + let address = 0n; + for (const hextet of hextets) address = (address << 16n) | BigInt(`0x${hextet}`); + return address; } -/** Non-throwing companion to {@link assertFetchableUrl}. */ -export function isFetchableUrl(rawUrl: string): boolean { - try { - assertFetchableUrl(rawUrl); - return true; - } catch { - return false; - } +function rangeFromIpv4(network: string, prefixLength: number): AddressRange { + const parsed = parseCanonicalIpv4(network); + if (parsed === undefined) throw new Error("Invalid internal IPv4 range"); + return Object.freeze({ network: parsed, prefixLength }); +} + +function rangeFromIpv6(network: string, prefixLength: number): AddressRange { + const parsed = parseIpv6(network); + if (parsed === undefined) throw new Error("Invalid internal IPv6 range"); + return Object.freeze({ network: parsed, prefixLength }); } -function isBlockedHost(hostname: string): boolean { - // Drop a single trailing dot: "localhost." / "intranet.host." is the absolute - // FQDN form and resolves to the same address as the dotless name, so it must be - // classified the same. (The URL parser already strips it from IPv4 literals, - // but not from registrable names.) - const host = hostname.toLowerCase().replace(/\.$/, ""); +const BLOCKED_IPV4 = IPV4_BLOCKED_RANGES.map(([network, prefixLength]) => + rangeFromIpv4(network, prefixLength), +); +const BLOCKED_IPV6 = IPV6_BLOCKED_RANGES.map(([network, prefixLength]) => + rangeFromIpv6(network, prefixLength), +); - // Names that always mean the local machine, regardless of DNS. - if (host === "localhost" || host.endsWith(".localhost")) return true; +function isInRange(address: bigint, range: AddressRange, bitLength: number): boolean { + const trailingBits = BigInt(bitLength - range.prefixLength); + return address >> trailingBits === range.network >> trailingBits; +} + +function classifyIpv4(address: bigint): LiteralAddressClassification { + return BLOCKED_IPV4.some((range) => isInRange(address, range, IPV4_BIT_LENGTH)) + ? "non-public-or-special-literal" + : "ordinary-public-literal"; +} - // IPv6 literals keep their brackets in URL.hostname (e.g. "[::1]"). - if (host.startsWith("[") && host.endsWith("]")) { - const groups = parseIpv6(host.slice(1, -1)); - return groups !== null && isBlockedIpv6(groups); +function classifyIpv6(address: bigint): LiteralAddressClassification { + // IPv4-mapped IPv6 addresses inherit the IPv4 classification. + if (address >> 32n === 0xffffn) return classifyIpv4(address & 0xffff_ffffn); + + if (BLOCKED_IPV6.some((range) => isInRange(address, range, IPV6_BIT_LENGTH))) { + return "non-public-or-special-literal"; } - // The WHATWG URL parser normalizes every IPv4 form (decimal, octal, hex, - // integer) to dotted-decimal, so this single check also covers obfuscated - // literals like http://2130706433/ or http://0x7f.1/. - const octets = parseIpv4(host); - if (octets) return isBlockedIpv4(octets); + // Only the globally allocated 2000::/3 space is accepted by default. Newly + // allocated special ranges outside it therefore fail closed until reviewed. + return address >> 125n === 1n ? "ordinary-public-literal" : "non-public-or-special-literal"; +} +function containsUnsafeCodePoint(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const following = value.charCodeAt(index + 1); + if (following < 0xdc00 || following > 0xdfff) return true; + index += 1; + continue; + } + if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) return true; + // Literal ASCII space is permitted inside a URL because WHATWG encodes it. + // Every other C0/C1 control remains forbidden. + if (codeUnit < 0x20 || (codeUnit >= 0x7f && codeUnit <= 0x9f)) return true; + if ( + codeUnit === 0x061c || + codeUnit === 0x200b || + codeUnit === 0x200c || + codeUnit === 0x200d || + codeUnit === 0x200e || + codeUnit === 0x200f || + (codeUnit >= 0x2028 && codeUnit <= 0x202e) || + (codeUnit >= 0x2066 && codeUnit <= 0x2069) || + codeUnit === 0xfeff + ) { + return true; + } + } return false; } -function parseIpv4(host: string): number[] | null { - const parts = host.split("."); - if (parts.length !== 4) return null; +function classifyHostname(hostname: string): HostClassification { + const ipv4 = parseCanonicalIpv4(hostname); + if (ipv4 !== undefined) return classifyIpv4(ipv4); - const octets: number[] = []; - for (const part of parts) { - if (!/^\d{1,3}$/.test(part)) return null; - const value = Number.parseInt(part, 10); - if (value > 255) return null; - octets.push(value); + const ipv6 = parseIpv6(hostname); + if (ipv6 !== undefined) return classifyIpv6(ipv6); + + const labels = hostname.split("."); + if ( + labels.length === 1 || + labels.some((label) => label.length === 0 || label.length > 63) || + hostname.length > 253 || + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname === "local" || + hostname.endsWith(".local") || + hostname === "internal" || + hostname.endsWith(".internal") || + hostname === "home.arpa" || + hostname.endsWith(".home.arpa") + ) { + return "non-public-or-special-literal"; } - return octets; + + return "hostname-requires-resolution"; } -function isBlockedIpv4(octets: number[]): boolean { - const [a, b] = octets; +function normalizedHostFromUrl(url: URL): string | undefined { + let hostname = url.hostname.toLowerCase(); + if (hostname.startsWith("[") && hostname.endsWith("]")) { + hostname = hostname.slice(1, -1); + } else if (hostname.endsWith(".")) { + hostname = hostname.slice(0, -1); + if (hostname.endsWith(".")) return undefined; + } + return hostname.length === 0 ? undefined : hostname; +} + +function exceedsTargetByteLimit(value: string): boolean { return ( - a === 0 || // 0.0.0.0/8 "this host" - a === 127 || // 127.0.0.0/8 loopback - a === 10 || // 10.0.0.0/8 private - (a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 private - (a === 192 && b === 168) || // 192.168.0.0/16 private - (a === 169 && b === 254) // 169.254.0.0/16 link-local (incl. cloud metadata) + value.length > MAX_NETWORK_TARGET_BYTES || + new TextEncoder().encode(value).byteLength > MAX_NETWORK_TARGET_BYTES ); } -/** Expand an IPv6 literal (no brackets) to its eight 16-bit groups. */ -function parseIpv6(input: string): number[] | null { - // Drop any zone id ("fe80::1%eth0"). - const zone = input.indexOf("%"); - let addr = zone === -1 ? input : input.slice(0, zone); - - // An embedded dotted-quad in the final group ("::ffff:127.0.0.1") becomes - // two hex groups so the rest of the parser only deals in hextets. - const lastColon = addr.lastIndexOf(":"); - const tail = addr.slice(lastColon + 1); - if (tail.includes(".")) { - const v4 = parseIpv4(tail); - if (!v4) return null; - const high = ((v4[0] << 8) | v4[1]).toString(16); - const low = ((v4[2] << 8) | v4[3]).toString(16); - addr = `${addr.slice(0, lastColon + 1)}${high}:${low}`; +function parseTargetPolicy(value: unknown): TargetPolicyResult { + if (typeof value !== "string" || value.length === 0) { + return { ok: false, reason: "empty" }; + } + if (value.length > MAX_NETWORK_TARGET_BYTES) return { ok: false, reason: "too-large" }; + // Reject leading and trailing whitespace rather than silently normalizing it. + if (value !== value.trim()) return { ok: false, reason: "empty" }; + if (containsUnsafeCodePoint(value)) return { ok: false, reason: "unsafe-unicode" }; + if (exceedsTargetByteLimit(value)) return { ok: false, reason: "too-large" }; + // WHATWG special-URL backslash normalization is not safe for an exact target + // interpreted later by a different transport. + if (value.includes("\\")) return { ok: false, reason: "malformed" }; + const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase(); + if (scheme !== "http" && scheme !== "https") { + return { ok: false, reason: scheme ? "unsupported-scheme" : "malformed" }; } + // Require an explicit authority spelling. WHATWG otherwise accepts and + // rewrites forms such as `http:example.com` and `http:///example.com`. + if (!/^https?:\/\/[^/?#]/i.test(value)) return { ok: false, reason: "malformed" }; - const halves = addr.split("::"); - if (halves.length > 2) return null; + let url: URL; + try { + url = new URL(value); + } catch { + return { ok: false, reason: "malformed" }; + } - const left = toGroups(halves[0]); - const right = halves.length === 2 ? toGroups(halves[1]) : []; - if (!left || !right) return null; + if (url.protocol !== "http:" && url.protocol !== "https:") { + return { ok: false, reason: "unsupported-scheme" }; + } + if (url.hash.length > 0) return { ok: false, reason: "malformed" }; - if (halves.length === 2) { - const missing = 8 - left.length - right.length; - if (missing < 1) return null; // "::" must stand for at least one zero group - return [...left, ...Array.from({ length: missing }, () => 0), ...right]; + const hostname = normalizedHostFromUrl(url); + if (!hostname) return { ok: false, reason: "malformed" }; + const port = url.port.length > 0 ? Number(url.port) : url.protocol === "https:" ? 443 : 80; + if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) { + return { ok: false, reason: "malformed" }; } - return left.length === 8 ? left : null; + return { ok: true, value: { url, hostClassification: classifyHostname(hostname) } }; } -function toGroups(segment: string): number[] | null { - if (segment === "") return []; - const groups: number[] = []; - for (const part of segment.split(":")) { - if (!/^[0-9a-f]{1,4}$/.test(part)) return null; - groups.push(Number.parseInt(part, 16)); +const UNSAFE_FETCH_URL_MESSAGES: Readonly> = Object.freeze({ + empty: "Refusing to fetch an empty URL.", + "too-large": "Refusing to fetch an oversized URL.", + malformed: "Refusing to fetch a malformed URL.", + "unsupported-scheme": "Refusing to fetch an unsupported URL scheme.", + "unsafe-unicode": "Refusing to fetch a URL containing unsafe characters.", + "non-public-or-special-host": "Refusing to fetch a non-public or special address.", +}); + +/** A stable, redacted target-policy error that never contains the rejected URL. */ +export class UnsafeFetchUrlError extends Error { + constructor(public readonly reason: UnsafeFetchUrlReason) { + super(UNSAFE_FETCH_URL_MESSAGES[reason]); + this.name = "UnsafeFetchUrlError"; } - return groups; -} - -function isBlockedIpv6(g: number[]): boolean { - // fc00::/7 unique-local. - if ((g[0] & 0xfe00) === 0xfc00) return true; - // fe80::/10 link-local. - if ((g[0] & 0xffc0) === 0xfe80) return true; - - // IPv4-mapped ("::ffff:a.b.c.d") and IPv4-compatible ("::a.b.c.d", which also - // covers ::1 loopback and :: unspecified): fold the trailing 32 bits back into - // an IPv4 address and reuse the IPv4 ranges. - const firstFiveZero = g.slice(0, 5).every((part) => part === 0); - if (firstFiveZero && (g[5] === 0xffff || g[5] === 0)) { - const v4 = [g[6] >> 8, g[6] & 0xff, g[7] >> 8, g[7] & 0xff]; - return isBlockedIpv4(v4); +} + +/** + * Applies the synchronous target policy and returns the canonical encoded + * HTTP(S) target. Credentials remain confined to this returned URL; failures + * expose only a stable reason and redacted message. + */ +export function assertFetchableUrl(rawUrl: string): URL { + const policy = parseTargetPolicy(rawUrl); + if (!policy.ok) throw new UnsafeFetchUrlError(policy.reason); + if (policy.value.hostClassification === "non-public-or-special-literal") { + throw new UnsafeFetchUrlError("non-public-or-special-host"); } - return false; + const encodedTarget = encodeUrlForRequest(policy.value.url.href); + if (exceedsTargetByteLimit(encodedTarget)) throw new UnsafeFetchUrlError("too-large"); + return new URL(encodedTarget); +} + +/** Non-throwing companion to {@link assertFetchableUrl}. */ +export function isFetchableUrl(rawUrl: string): boolean { + try { + assertFetchableUrl(rawUrl); + return true; + } catch { + return false; + } } diff --git a/src/utility/fetchChapters.test.ts b/src/utility/fetchChapters.test.ts index f64f128..bee04e7 100644 --- a/src/utility/fetchChapters.test.ts +++ b/src/utility/fetchChapters.test.ts @@ -1,20 +1,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { fetchChapters } from "./fetchChapters"; -const mockRequestWithTimeout = vi.hoisted(() => vi.fn()); +const mockFetchTextWithTimeout = vi.hoisted(() => vi.fn()); vi.mock("./networkRequest", () => ({ - requestWithTimeout: mockRequestWithTimeout, + fetchTextWithTimeout: mockFetchTextWithTimeout, })); describe("fetchChapters", () => { beforeEach(() => { - mockRequestWithTimeout.mockReset(); + mockFetchTextWithTimeout.mockReset(); }); it("normalizes malformed chapter entries instead of returning raw JSON", async () => { - mockRequestWithTimeout.mockResolvedValue({ - text: JSON.stringify({ + mockFetchTextWithTimeout.mockResolvedValue( + JSON.stringify({ version: "1.2.0", chapters: [ { startTime: 65, title: "Deep Dive" }, @@ -25,24 +25,31 @@ describe("fetchChapters", () => { { startTime: 0, title: "Intro" }, ], }), - }); + ); await expect(fetchChapters("https://example.com/chapters.json")).resolves.toEqual([ { startTime: 0, title: "Intro" }, { startTime: 35, title: "" }, { startTime: 65, title: "Deep Dive" }, ]); + expect(mockFetchTextWithTimeout).toHaveBeenCalledWith("https://example.com/chapters.json", { + timeoutMs: 10_000, + maxResponseBytes: 4_000_000, + acceptedStatuses: [200], + }); }); - it("ignores unsupported chapter URL protocols", async () => { + it("lets the shared request boundary reject unsupported chapter URL protocols", async () => { + mockFetchTextWithTimeout.mockRejectedValue( + new Error("Network request target is not allowed."), + ); + await expect(fetchChapters("file:///tmp/chapters.json")).resolves.toEqual([]); - expect(mockRequestWithTimeout).not.toHaveBeenCalled(); + expect(mockFetchTextWithTimeout).toHaveBeenCalledOnce(); }); it("ignores oversized chapter payloads", async () => { - mockRequestWithTimeout.mockResolvedValue({ - text: " ".repeat(1_000_001), - }); + mockFetchTextWithTimeout.mockResolvedValue(" ".repeat(1_000_001)); await expect(fetchChapters("https://example.com/chapters.json")).resolves.toEqual([]); }); diff --git a/src/utility/fetchChapters.ts b/src/utility/fetchChapters.ts index b55319b..40be9fc 100644 --- a/src/utility/fetchChapters.ts +++ b/src/utility/fetchChapters.ts @@ -1,25 +1,31 @@ import type { Chapter, ChaptersData } from "src/types/Chapter"; -import { requestWithTimeout } from "./networkRequest"; +import { fetchTextWithTimeout } from "./networkRequest"; import { normalizeChapters } from "./normalizeChapters"; const MAX_CHAPTERS_RESPONSE_CHARS = 1_000_000; +const MAX_CHAPTERS_RESPONSE_BYTES = MAX_CHAPTERS_RESPONSE_CHARS * 4; +const CHAPTERS_REQUEST_TIMEOUT_MS = 10_000; /** * Fetches and parses podcast chapters from a chapters URL. * Returns an empty array if the URL is invalid or the request fails. */ export async function fetchChapters(chaptersUrl: string): Promise { - if (!chaptersUrl || !isSupportedChaptersUrl(chaptersUrl)) { + if (!chaptersUrl) { return []; } try { - const response = await requestWithTimeout(chaptersUrl, { timeoutMs: 10000 }); - if (response.text.length > MAX_CHAPTERS_RESPONSE_CHARS) { + const responseText = await fetchTextWithTimeout(chaptersUrl, { + timeoutMs: CHAPTERS_REQUEST_TIMEOUT_MS, + maxResponseBytes: MAX_CHAPTERS_RESPONSE_BYTES, + acceptedStatuses: [200], + }); + if (responseText.length > MAX_CHAPTERS_RESPONSE_CHARS) { return []; } - const data: ChaptersData = JSON.parse(response.text); + const data: ChaptersData = JSON.parse(responseText); if (!data.chapters || !Array.isArray(data.chapters)) { return []; @@ -31,12 +37,3 @@ export async function fetchChapters(chaptersUrl: string): Promise { return []; } } - -function isSupportedChaptersUrl(chaptersUrl: string): boolean { - try { - const url = new URL(chaptersUrl); - return url.protocol === "https:" || url.protocol === "http:"; - } catch { - return false; - } -} diff --git a/src/utility/networkRequest.test.ts b/src/utility/networkRequest.test.ts new file mode 100644 index 0000000..2db30e2 --- /dev/null +++ b/src/utility/networkRequest.test.ts @@ -0,0 +1,856 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RequestUrlResponse } from "obsidian"; + +const nativeRequest = vi.hoisted(() => vi.fn()); + +vi.mock("obsidian", () => ({ requestUrl: nativeRequest })); + +import { + MAX_NETWORK_TIMEOUT_MS, + NetworkError, + type NetworkErrorCode, + type NetworkRequestImplementation, + type NetworkRequestOptions, + type NetworkRequestParameters, + TimeoutError, + fetchJsonWithTimeout, + fetchTextWithTimeout, + requestWithTimeout, +} from "./networkRequest"; + +const ERROR_MESSAGES = { + "invalid-options": "Network request options are invalid.", + "unsafe-target": "Network request target is not allowed.", + "request-too-large": "Network request body exceeds the configured limit.", + "response-too-large": "Network response exceeds the configured limit.", + "unexpected-status": "Network response status is not accepted.", + timeout: "Network request timed out.", + aborted: "Network request was aborted.", + "transport-failure": "Network request failed.", + "invalid-response": "Network response is invalid.", +} as const satisfies Record; + +function makeResponse({ + status = 200, + bytes = 0, + text = "", + json = {}, +}: { + status?: number; + bytes?: number; + text?: string; + json?: unknown; +} = {}): RequestUrlResponse { + return { + status, + headers: {}, + arrayBuffer: new Uint8Array(bytes).buffer, + text, + json, + }; +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function implementation(result: PromiseLike = Promise.resolve(makeResponse())) { + return vi.fn((_parameters: NetworkRequestParameters) => result); +} + +function poisonedNetworkError(code: NetworkErrorCode, marker: string): NetworkError { + const error = new NetworkError(code); + Object.defineProperties(error, { + message: { value: marker, enumerable: true }, + cause: { value: marker, enumerable: true }, + url: { value: `https://example.com/?token=${marker}`, enumerable: true }, + }); + return error; +} + +async function rejectionOf(operation: Promise): Promise { + try { + await operation; + } catch (error) { + expect(error).toBeInstanceOf(NetworkError); + return error as NetworkError; + } + throw new Error("Expected the network operation to reject."); +} + +function expectRedactedError( + error: NetworkError, + code: NetworkErrorCode, + markers: readonly string[] = [], +): void { + expect(error.code).toBe(code); + expect(error.message).toBe( + code === "unexpected-status" && error.status !== undefined + ? `Network response returned HTTP ${error.status}.` + : ERROR_MESSAGES[code], + ); + expect(error).not.toHaveProperty("url"); + expect(error).not.toHaveProperty("cause"); + const exposed = `${String(error)} ${JSON.stringify(error)}`; + for (const marker of markers) expect(exposed).not.toContain(marker); +} + +beforeEach(() => { + nativeRequest.mockReset(); + nativeRequest.mockResolvedValue(makeResponse()); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("requestWithTimeout target gate and forwarding", () => { + it("validates the raw target before calling native transport and preserves URL compatibility", async () => { + const body = "payload"; + + await requestWithTimeout("https://EXAMPLE.com/a(b)?value=hello world", { + method: "POST", + contentType: "text/plain", + headers: { "X-Test": "yes" }, + body, + }); + + expect(nativeRequest).toHaveBeenCalledTimes(1); + expect(nativeRequest).toHaveBeenCalledWith({ + url: "https://example.com/a%28b%29?value=hello%20world", + method: "POST", + contentType: "text/plain", + headers: { "X-Test": "yes" }, + body, + throw: false, + }); + }); + + it("blocks an unsafe target before either native or injected transport", async () => { + const request = implementation(); + const marker = "private-target-marker"; + const error = await rejectionOf( + requestWithTimeout(`http://127.0.0.1/${marker}?token=${marker}`, { request }), + ); + + expectRedactedError(error, "unsafe-target", [marker, "127.0.0.1"]); + expect(request).not.toHaveBeenCalled(); + expect(nativeRequest).not.toHaveBeenCalled(); + }); + + it("rejects boundary whitespace instead of normalizing before policy", async () => { + const request = implementation(); + const error = await rejectionOf( + requestWithTimeout(" https://example.com/feed ", { request }), + ); + + expectRedactedError(error, "unsafe-target"); + expect(request).not.toHaveBeenCalled(); + }); + + it("supports an injected request implementation without weakening policy", async () => { + const request = implementation(Promise.resolve(makeResponse({ bytes: 2 }))); + + const response = await requestWithTimeout("https://example.com/feed", { request }); + + expect(response.arrayBuffer.byteLength).toBe(2); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ url: "https://example.com/feed", throw: false }), + ); + expect(nativeRequest).not.toHaveBeenCalled(); + }); +}); + +describe("requestWithTimeout option and body bounds", () => { + it.each([ + { timeoutMs: 0 }, + { timeoutMs: 1.5 }, + { timeoutMs: MAX_NETWORK_TIMEOUT_MS + 1 }, + { maxRequestBodyBytes: -1 }, + { maxRequestBodyBytes: 1.5 }, + { maxResponseBytes: -1 }, + { maxResponseBytes: Number.MAX_SAFE_INTEGER + 1 }, + { acceptedStatuses: [] }, + { acceptedStatuses: [99] }, + { acceptedStatuses: [600] }, + { signal: {} }, + { request: {} }, + ])("rejects invalid options before transport: %j", async (candidate) => { + const error = await rejectionOf( + requestWithTimeout( + "https://example.com/feed", + candidate as unknown as NetworkRequestOptions, + ), + ); + + expectRedactedError(error, "invalid-options"); + expect(nativeRequest).not.toHaveBeenCalled(); + }); + + it("snapshots every option and signal member getter exactly once", async () => { + const request = implementation(); + const signalGetters = { + aborted: vi.fn(() => false), + addEventListener: vi.fn(() => () => {}), + removeEventListener: vi.fn(() => () => {}), + }; + const signal = Object.create(null) as AbortSignal; + for (const [name, getter] of Object.entries(signalGetters)) { + Object.defineProperty(signal, name, { get: getter }); + } + const statuses = [] as number[]; + const statusGetter = vi.fn(() => 200); + Object.defineProperty(statuses, "0", { get: statusGetter, enumerable: true }); + const headers = Object.create(null) as Record; + const headerGetter = vi.fn(() => "yes"); + Object.defineProperty(headers, "X-Test", { get: headerGetter, enumerable: true }); + const values = { + timeoutMs: 1_000, + maxRequestBodyBytes: 4, + maxResponseBytes: 8, + acceptedStatuses: statuses, + signal, + method: "POST", + contentType: "text/plain", + headers, + body: "body", + request, + }; + const options = Object.create(null) as NetworkRequestOptions; + const optionGetters = Object.fromEntries( + Object.entries(values).map(([name, value]) => { + const getter = vi.fn(() => value); + Object.defineProperty(options, name, { get: getter }); + return [name, getter]; + }), + ) as Record>; + + await expect( + requestWithTimeout("https://example.com/feed", options), + ).resolves.toMatchObject({ + status: 200, + }); + + for (const getter of Object.values(optionGetters)) expect(getter).toHaveBeenCalledOnce(); + for (const getter of Object.values(signalGetters)) expect(getter).toHaveBeenCalledOnce(); + expect(statusGetter).toHaveBeenCalledOnce(); + expect(headerGetter).toHaveBeenCalledOnce(); + expect(request).toHaveBeenCalledOnce(); + }); + + it("finishes deep option validation before starting transport", async () => { + const marker = "status-option-secret-marker"; + const request = implementation(); + const statuses = [] as number[]; + Object.defineProperty(statuses, "0", { + get() { + throw new Error(marker); + }, + }); + + const error = await rejectionOf( + requestWithTimeout("https://example.com/feed", { + request, + acceptedStatuses: statuses, + }), + ); + + expectRedactedError(error, "invalid-options", [marker]); + expect(request).not.toHaveBeenCalled(); + expect(nativeRequest).not.toHaveBeenCalled(); + }); + + it("counts UTF-8 request bytes exactly and stops before transport when over the cap", async () => { + const request = implementation(); + const oversized = await rejectionOf( + requestWithTimeout("https://example.com/feed", { + request, + body: "😀", + maxRequestBodyBytes: 3, + }), + ); + + expectRedactedError(oversized, "request-too-large"); + expect(request).not.toHaveBeenCalled(); + + await expect( + requestWithTimeout("https://example.com/feed", { + request, + body: "😀", + maxRequestBodyBytes: 4, + }), + ).resolves.toMatchObject({ status: 200 }); + expect(request).toHaveBeenCalledTimes(1); + }); + + it("bounds ArrayBuffer request bodies", async () => { + const request = implementation(); + const error = await rejectionOf( + requestWithTimeout("https://example.com/feed", { + request, + body: new Uint8Array(5).buffer, + maxRequestBodyBytes: 4, + }), + ); + + expectRedactedError(error, "request-too-large"); + expect(request).not.toHaveBeenCalled(); + }); + + it("accepts genuine iframe-realm ArrayBuffers for request and response bodies", async () => { + const iframe = document.createElement("iframe"); + document.body.append(iframe); + const foreignWindow = iframe.contentWindow; + if (!foreignWindow) throw new Error("Expected the iframe to have a window."); + const foreignGlobal = foreignWindow as unknown as typeof globalThis; + const requestBody = new foreignGlobal.ArrayBuffer(4); + const responseBody = new foreignGlobal.ArrayBuffer(3); + const request = implementation(); + + try { + const response = { ...makeResponse(), arrayBuffer: responseBody }; + request.mockReturnValueOnce(Promise.resolve(response)); + + await expect( + requestWithTimeout("https://example.com/feed", { + request, + body: requestBody, + maxRequestBodyBytes: requestBody.byteLength, + maxResponseBytes: responseBody.byteLength, + }), + ).resolves.toMatchObject({ status: 200, arrayBuffer: responseBody }); + + expect(requestBody).not.toBeInstanceOf(ArrayBuffer); + expect(responseBody).not.toBeInstanceOf(ArrayBuffer); + expect(request).toHaveBeenCalledWith(expect.objectContaining({ body: requestBody })); + } finally { + iframe.remove(); + } + }); + + it("rejects ArrayBuffer views and SharedArrayBuffers as bodies and responses", async () => { + const invalidBuffers: readonly unknown[] = [new Uint8Array(4), new SharedArrayBuffer(4)]; + + for (const invalidBuffer of invalidBuffers) { + const bodyRequest = implementation(); + const bodyError = await rejectionOf( + requestWithTimeout("https://example.com/feed", { + request: bodyRequest, + body: invalidBuffer as ArrayBuffer, + }), + ); + expectRedactedError(bodyError, "invalid-options"); + expect(bodyRequest).not.toHaveBeenCalled(); + + const response = { + ...makeResponse(), + arrayBuffer: invalidBuffer as ArrayBuffer, + }; + const responseError = await rejectionOf( + requestWithTimeout("https://example.com/feed", { + request: implementation(Promise.resolve(response)), + }), + ); + expectRedactedError(responseError, "invalid-response"); + } + }); +}); + +describe("requestWithTimeout response policy", () => { + it("projects binary responses without touching eager text or JSON getters", async () => { + const marker = "unused-response-field-marker"; + const response = makeResponse({ bytes: 3 }); + const textGetter = vi.fn(() => { + throw new Error(marker); + }); + const jsonGetter = vi.fn(() => { + throw new Error(marker); + }); + Object.defineProperties(response, { + text: { get: textGetter }, + json: { get: jsonGetter }, + }); + + await expect( + requestWithTimeout("https://example.com/feed", { + request: implementation(Promise.resolve(response)), + }), + ).resolves.toMatchObject({ status: 200, arrayBuffer: response.arrayBuffer }); + expect(textGetter).not.toHaveBeenCalled(); + expect(jsonGetter).not.toHaveBeenCalled(); + }); + + it("returns the exact status and body snapshots that passed validation", async () => { + const smallBody = new ArrayBuffer(2); + const largeBody = new ArrayBuffer(20); + const statusGetter = vi.fn().mockReturnValueOnce(200).mockReturnValue(500); + const bodyGetter = vi.fn().mockReturnValueOnce(smallBody).mockReturnValue(largeBody); + const response = makeResponse(); + Object.defineProperties(response, { + status: { get: statusGetter }, + arrayBuffer: { get: bodyGetter }, + }); + + const projected = await requestWithTimeout("https://example.com/feed", { + request: implementation(Promise.resolve(response)), + maxResponseBytes: 2, + }); + + expect(projected.status).toBe(200); + expect(projected.arrayBuffer).toBe(smallBody); + expect(statusGetter).toHaveBeenCalledOnce(); + expect(bodyGetter).toHaveBeenCalledOnce(); + }); + + it("rejects a buffered response over the configured byte ceiling", async () => { + const request = implementation(Promise.resolve(makeResponse({ bytes: 5 }))); + const error = await rejectionOf( + requestWithTimeout("https://example.com/feed", { + request, + maxResponseBytes: 4, + }), + ); + + expectRedactedError(error, "response-too-large"); + }); + + it("accepts 2xx by default and rejects an exposed redirect", async () => { + await expect( + requestWithTimeout("https://example.com/feed", { + request: implementation(Promise.resolve(makeResponse({ status: 206 }))), + }), + ).resolves.toMatchObject({ status: 206 }); + + const error = await rejectionOf( + requestWithTimeout("https://example.com/feed", { + request: implementation(Promise.resolve(makeResponse({ status: 302 }))), + }), + ); + expectRedactedError(error, "unexpected-status"); + expect(error.status).toBe(302); + }); + + it("supports an explicit status allowlist for range and provider calls", async () => { + const request = implementation(Promise.resolve(makeResponse({ status: 416 }))); + + await expect( + requestWithTimeout("https://example.com/media", { + request, + acceptedStatuses: [200, 206, 416], + }), + ).resolves.toMatchObject({ status: 416 }); + }); + + it("redacts the target and response body from a status failure", async () => { + const marker = "status-secret-marker"; + const request = implementation( + Promise.resolve(makeResponse({ status: 403, text: marker, json: { message: marker } })), + ); + const error = await rejectionOf( + requestWithTimeout(`https://user:pass@example.com/feed?token=${marker}`, { request }), + ); + + expectRedactedError(error, "unexpected-status", [marker, "user", "pass"]); + expect(error.status).toBe(403); + }); + + it.each([ + null, + {}, + { status: Number.NaN, arrayBuffer: new ArrayBuffer(0) }, + { status: 200, arrayBuffer: "not-bytes" }, + ])("maps a malformed native response to a stable error: %j", async (value) => { + const request = implementation(Promise.resolve(value as RequestUrlResponse)); + const error = await rejectionOf( + requestWithTimeout("https://example.com/feed", { request }), + ); + + expectRedactedError(error, "invalid-response"); + }); +}); + +describe("requestWithTimeout timeout and cancellation", () => { + it("settles promptly on timeout and observes a later native rejection", async () => { + vi.useFakeTimers(); + const late = deferred(); + const marker = "late-transport-marker"; + const operation = requestWithTimeout("https://example.com/feed", { + request: implementation(late.promise), + timeoutMs: 25, + }); + const observed = rejectionOf(operation); + + await vi.advanceTimersByTimeAsync(25); + const error = await observed; + expect(error).toBeInstanceOf(TimeoutError); + expectRedactedError(error, "timeout", [marker]); + + late.reject(new Error(marker)); + await Promise.resolve(); + }); + + it("settles promptly on timeout and discards a later native response", async () => { + vi.useFakeTimers(); + const late = deferred(); + const operation = requestWithTimeout("https://example.com/feed", { + request: implementation(late.promise), + timeoutMs: 10, + }); + const observed = rejectionOf(operation); + + await vi.advanceTimersByTimeAsync(10); + expectRedactedError(await observed, "timeout"); + + late.resolve(makeResponse({ text: "late" })); + await Promise.resolve(); + }); + + it("rejects an already-aborted signal before transport without exposing its reason", async () => { + const controller = new AbortController(); + const marker = "pre-abort-marker"; + controller.abort(new Error(marker)); + const request = implementation(); + + const error = await rejectionOf( + requestWithTimeout("https://example.com/feed", { + request, + signal: controller.signal, + }), + ); + + expectRedactedError(error, "aborted", [marker]); + expect(request).not.toHaveBeenCalled(); + }); + + it("maps hostile signal access and listener failures to stable errors", async () => { + const marker = "signal-secret-marker"; + const request = implementation(); + const throwingGetter = { + get aborted() { + throw new Error(marker); + }, + addEventListener: () => {}, + removeEventListener: () => {}, + } as unknown as AbortSignal; + + const getterError = await rejectionOf( + requestWithTimeout("https://example.com/feed", { + request, + signal: throwingGetter, + }), + ); + expectRedactedError(getterError, "invalid-options", [marker]); + expect(request).not.toHaveBeenCalled(); + + const throwingListener = { + aborted: false, + addEventListener() { + throw new Error(marker); + }, + removeEventListener() { + throw new Error(marker); + }, + } as unknown as AbortSignal; + const listenerError = await rejectionOf( + requestWithTimeout("https://example.com/feed", { + request, + signal: throwingListener, + }), + ); + expectRedactedError(listenerError, "invalid-options", [marker]); + expect(request).not.toHaveBeenCalled(); + }); + + it("rejects an abort triggered by a later header getter before transport", async () => { + const controller = new AbortController(); + const marker = "late-option-abort-marker"; + const request = implementation(); + const headers = Object.create(null) as Record; + Object.defineProperty(headers, "X-Test", { + enumerable: true, + get() { + controller.abort(new Error(marker)); + return "yes"; + }, + }); + + const error = await rejectionOf( + requestWithTimeout("https://example.com/feed", { + request, + signal: controller.signal, + headers, + }), + ); + + expectRedactedError(error, "aborted", [marker]); + expect(request).not.toHaveBeenCalled(); + }); + + it("cleans up a shape-compatible signal that aborts during listener registration", async () => { + vi.useFakeTimers(); + const late = deferred(); + const request = implementation(late.promise); + const listeners = new Set(); + const signal = { + aborted: false, + addEventListener(_type: string, listener: EventListener) { + listeners.add(listener); + listener(new Event("abort")); + }, + removeEventListener(_type: string, listener: EventListener) { + listeners.delete(listener); + }, + } as unknown as AbortSignal; + + const error = await rejectionOf( + requestWithTimeout("https://example.com/feed", { + request, + signal, + timeoutMs: MAX_NETWORK_TIMEOUT_MS, + }), + ); + + expectRedactedError(error, "aborted"); + expect(request).not.toHaveBeenCalled(); + expect(listeners).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + late.resolve(makeResponse()); + await Promise.resolve(); + }); + + it("settles on an in-flight abort and observes the late native rejection", async () => { + const controller = new AbortController(); + const late = deferred(); + const marker = "mid-abort-marker"; + const operation = requestWithTimeout("https://example.com/feed", { + request: implementation(late.promise), + signal: controller.signal, + }); + const observed = rejectionOf(operation); + + controller.abort(new Error(marker)); + const error = await observed; + expectRedactedError(error, "aborted", [marker]); + + late.reject(new Error(marker)); + await Promise.resolve(); + }); + + it("closes the abort race between native start and listener registration", async () => { + const controller = new AbortController(); + const late = deferred(); + const request = vi.fn((_parameters: NetworkRequestParameters) => { + controller.abort(); + return late.promise; + }); + + const error = await rejectionOf( + requestWithTimeout("https://example.com/feed", { + request, + signal: controller.signal, + }), + ); + + expectRedactedError(error, "aborted"); + late.resolve(makeResponse()); + await Promise.resolve(); + }); + + it("removes the abort listener and timeout after a successful response", async () => { + vi.useFakeTimers(); + const controller = new AbortController(); + const remove = vi.spyOn(controller.signal, "removeEventListener"); + + await requestWithTimeout("https://example.com/feed", { + request: implementation(), + signal: controller.signal, + }); + + expect(remove).toHaveBeenCalledWith("abort", expect.any(Function)); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe("requestWithTimeout error redaction", () => { + it("copies an option getter's NetworkError into a fresh stable error", async () => { + const marker = "option-network-error-marker"; + const poisoned = poisonedNetworkError("invalid-options", marker); + const options = Object.create(null) as NetworkRequestOptions; + Object.defineProperty(options, "timeoutMs", { + get() { + throw poisoned; + }, + }); + + const error = await rejectionOf(requestWithTimeout("https://example.com/feed", options)); + + expect(error).not.toBe(poisoned); + expectRedactedError(error, "invalid-options", [marker]); + expect(nativeRequest).not.toHaveBeenCalled(); + }); + + it("does not inspect a hostile error thrown by an option getter", async () => { + const marker = "changing-network-error-code-marker"; + const poisoned = poisonedNetworkError("invalid-options", marker); + const codeGetter = vi.fn().mockReturnValueOnce("invalid-options").mockReturnValue(marker); + Object.defineProperty(poisoned, "code", { get: codeGetter }); + const options = Object.create(null) as NetworkRequestOptions; + Object.defineProperty(options, "timeoutMs", { + get() { + throw poisoned; + }, + }); + + const error = await rejectionOf(requestWithTimeout("https://example.com/feed", options)); + + expect(codeGetter).not.toHaveBeenCalled(); + expect(error).not.toBe(poisoned); + expectRedactedError(error, "invalid-options", [marker]); + expect(nativeRequest).not.toHaveBeenCalled(); + }); + + it("copies a response getter's NetworkError into a fresh stable error", async () => { + const marker = "response-network-error-marker"; + const poisoned = poisonedNetworkError("unsafe-target", marker); + const response = makeResponse(); + Object.defineProperty(response, "arrayBuffer", { + get() { + throw poisoned; + }, + }); + + const error = await rejectionOf( + requestWithTimeout("https://example.com/feed", { + request: implementation(Promise.resolve(response)), + }), + ); + + expect(error).not.toBe(poisoned); + expectRedactedError(error, "invalid-response", [marker]); + }); + + it("copies a synchronous injected NetworkError into a fresh stable error", async () => { + const marker = "transport-network-error-marker"; + const poisoned = poisonedNetworkError("transport-failure", marker); + const request: NetworkRequestImplementation = () => { + throw poisoned; + }; + + const error = await rejectionOf( + requestWithTimeout("https://example.com/feed", { request }), + ); + + expect(error).not.toBe(poisoned); + expectRedactedError(error, "transport-failure", [marker]); + }); + + it.each(["reject", "throw"])( + "maps a native %s without exposing raw transport details", + async (mode) => { + const marker = "transport-secret-marker"; + const rawError = new Error(`native failed at https://example.com/?token=${marker}`); + const request: NetworkRequestImplementation = + mode === "reject" + ? () => Promise.reject(rawError) + : () => { + throw rawError; + }; + + const error = await rejectionOf( + requestWithTimeout(`https://example.com/feed?token=${marker}`, { request }), + ); + + expectRedactedError(error, "transport-failure", [marker, "native failed"]); + }, + ); +}); + +describe("fetch compatibility helpers", () => { + it("returns typed JSON and text through the same policy options", async () => { + const value = { ok: true }; + const jsonRequest = implementation( + Promise.resolve(makeResponse({ text: JSON.stringify(value) })), + ); + const textRequest = implementation(Promise.resolve(makeResponse({ text: "hello" }))); + + await expect( + fetchJsonWithTimeout("https://example.com/data", { + request: jsonRequest, + acceptedStatuses: [200], + }), + ).resolves.toEqual(value); + await expect( + fetchTextWithTimeout("https://example.com/data", { + request: textRequest, + maxResponseBytes: 1024, + }), + ).resolves.toBe("hello"); + }); + + it("parses stable text without touching the native JSON accessor", async () => { + const marker = "json-secret-marker"; + const response = makeResponse({ text: '{"ok":true}' }); + const jsonGetter = vi.fn(() => { + throw new Error(marker); + }); + Object.defineProperty(response, "json", { + get: jsonGetter, + }); + await expect( + fetchJsonWithTimeout("https://example.com/data", { + request: implementation(Promise.resolve(response)), + }), + ).resolves.toEqual({ ok: true }); + + expect(jsonGetter).not.toHaveBeenCalled(); + }); + + it.each([ + { label: "ASCII", text: "abcd", maximumBytes: 3 }, + { label: "multibyte", text: "😀", maximumBytes: 3 }, + { label: "unpaired surrogate", text: "\ud800", maximumBytes: 2 }, + ])( + "rejects $label text whose UTF-8 bytes exceed the response limit", + async ({ text, maximumBytes }) => { + const error = await rejectionOf( + fetchTextWithTimeout("https://example.com/data", { + request: implementation(Promise.resolve(makeResponse({ text }))), + maxResponseBytes: maximumBytes, + }), + ); + + expectRedactedError(error, "response-too-large", [text]); + }, + ); + + it.each([ + { label: "ASCII", text: "abcd", maximumBytes: 4 }, + { label: "two-byte scalar", text: "é", maximumBytes: 2 }, + { label: "surrogate pair", text: "😀", maximumBytes: 4 }, + { label: "unpaired surrogate", text: "\ud800", maximumBytes: 3 }, + ])("accepts $label text at the exact UTF-8 response limit", async ({ text, maximumBytes }) => { + await expect( + fetchTextWithTimeout("https://example.com/data", { + request: implementation(Promise.resolve(makeResponse({ text }))), + maxResponseBytes: maximumBytes, + }), + ).resolves.toBe(text); + }); + + it("rejects a non-string text field as an invalid response", async () => { + const response = { ...makeResponse(), text: 42 } as unknown as RequestUrlResponse; + const error = await rejectionOf( + fetchTextWithTimeout("https://example.com/data", { + request: implementation(Promise.resolve(response)), + }), + ); + + expectRedactedError(error, "invalid-response"); + }); +}); diff --git a/src/utility/networkRequest.ts b/src/utility/networkRequest.ts index 6003c06..c7b0038 100644 --- a/src/utility/networkRequest.ts +++ b/src/utility/networkRequest.ts @@ -1,100 +1,648 @@ import { requestUrl, type RequestUrlResponse } from "obsidian"; +import { assertFetchableUrl } from "./assertFetchableUrl"; -const DEFAULT_TIMEOUT_MS = 30000; // 30 seconds +export const DEFAULT_NETWORK_TIMEOUT_MS = 30_000; +export const MAX_NETWORK_TIMEOUT_MS = 2_147_483_647; +export const DEFAULT_MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024; +export const DEFAULT_MAX_RESPONSE_BYTES = 32 * 1024 * 1024; + +const ARRAY_BUFFER_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor( + ArrayBuffer.prototype, + "byteLength", +)?.get; + +export type NetworkErrorCode = + | "invalid-options" + | "unsafe-target" + | "request-too-large" + | "response-too-large" + | "unexpected-status" + | "timeout" + | "aborted" + | "transport-failure" + | "invalid-response"; + +const ERROR_MESSAGES = { + "invalid-options": "Network request options are invalid.", + "unsafe-target": "Network request target is not allowed.", + "request-too-large": "Network request body exceeds the configured limit.", + "response-too-large": "Network response exceeds the configured limit.", + "unexpected-status": "Network response status is not accepted.", + timeout: "Network request timed out.", + aborted: "Network request was aborted.", + "transport-failure": "Network request failed.", + "invalid-response": "Network response is invalid.", +} as const satisfies Record; +const NETWORK_ERROR_CODES: ReadonlySet = new Set(Object.keys(ERROR_MESSAGES)); export class NetworkError extends Error { + public readonly status?: number; + constructor( - message: string, - public readonly url: string, - public readonly cause?: unknown, + public readonly code: NetworkErrorCode, + status?: number, ) { - super(message); + const safeStatus = + code === "unexpected-status" && + Number.isSafeInteger(status) && + status !== undefined && + status >= 100 && + status <= 599 + ? status + : undefined; + super( + safeStatus === undefined + ? ERROR_MESSAGES[code] + : `Network response returned HTTP ${safeStatus}.`, + ); this.name = "NetworkError"; + this.status = safeStatus; } } export class TimeoutError extends NetworkError { - constructor(url: string, timeoutMs: number) { - super(`Request timed out after ${timeoutMs}ms`, url); + constructor() { + super("timeout"); this.name = "TimeoutError"; } } -/** - * Makes a network request with timeout protection. - * Throws TimeoutError if the request takes longer than the specified timeout. - * Throws NetworkError for other network-related failures. - */ -export async function requestWithTimeout( - url: string, - options: { - timeoutMs?: number; - method?: string; - headers?: Record; - body?: string; - } = {}, +function copyRedactedNetworkError(error: unknown, fallbackCode: NetworkErrorCode): NetworkError { + try { + if (error instanceof NetworkError) { + const code: unknown = error.code; + if (typeof code === "string" && NETWORK_ERROR_CODES.has(code)) { + const validatedCode = code as NetworkErrorCode; + return validatedCode === "timeout" + ? new TimeoutError() + : new NetworkError(validatedCode, error.status); + } + } + } catch { + // Hostile getters/proxies are replaced by the stable fallback below. + } + return new NetworkError(fallbackCode); +} + +export interface NetworkRequestOptions { + readonly timeoutMs?: number; + readonly maxRequestBodyBytes?: number; + readonly maxResponseBytes?: number; + readonly acceptedStatuses?: readonly number[]; + readonly signal?: AbortSignal; + readonly method?: string; + readonly contentType?: string; + readonly headers?: Readonly>; + readonly body?: string | ArrayBuffer; + /** @internal Trusted test seam. Never populate from application or user input. */ + readonly request?: NetworkRequestImplementation; +} + +export interface NetworkRequestParameters { + readonly url: string; + readonly method?: string; + readonly contentType?: string; + readonly headers?: Record; + readonly body?: string | ArrayBuffer; + readonly throw: false; +} + +export type NetworkRequestImplementation = ( + parameters: NetworkRequestParameters, +) => PromiseLike; + +interface PreparedNetworkRequestOptions { + readonly timeoutMs: number; + readonly maxRequestBodyBytes: number; + readonly maxResponseBytes: number; + readonly acceptedStatuses?: ReadonlySet; + readonly signal?: PreparedAbortSignal; + readonly method?: string; + readonly contentType?: string; + readonly headers?: Record; + readonly body?: string | ArrayBuffer; + readonly request: NetworkRequestImplementation; +} + +interface NetworkRequestOptionsSnapshot { + readonly timeoutMs: unknown; + readonly maxRequestBodyBytes: unknown; + readonly maxResponseBytes: unknown; + readonly acceptedStatuses: unknown; + readonly signal: unknown; + readonly method: unknown; + readonly contentType: unknown; + readonly headers: unknown; + readonly body: unknown; + readonly request: unknown; +} + +interface PreparedAbortSignal { + readonly target: AbortSignal; + readonly addEventListener: EventTarget["addEventListener"]; + readonly removeEventListener: EventTarget["removeEventListener"]; +} + +export interface NetworkBinaryResponse { + readonly status: number; + readonly headers: Record; + readonly arrayBuffer: ArrayBuffer; +} + +interface NetworkTextResponse { + readonly status: number; + readonly arrayBuffer: ArrayBuffer; + readonly text: string; +} + +function invalidOptions(): NetworkError { + return new NetworkError("invalid-options"); +} + +function isNonNegativeSafeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function prepareAbortSignal(value: unknown): PreparedAbortSignal | undefined { + if (value === undefined) return undefined; + if (typeof value !== "object" || value === null) throw invalidOptions(); + + try { + const addEventListener: unknown = Reflect.get(value, "addEventListener"); + const removeEventListener: unknown = Reflect.get(value, "removeEventListener"); + if (typeof addEventListener !== "function" || typeof removeEventListener !== "function") { + throw invalidOptions(); + } + return { + target: value as AbortSignal, + addEventListener: addEventListener as EventTarget["addEventListener"], + removeEventListener: removeEventListener as EventTarget["removeEventListener"], + }; + } catch { + throw invalidOptions(); + } +} + +function readAbortState(signal: PreparedAbortSignal): boolean { + try { + const aborted: unknown = Reflect.get(signal.target, "aborted"); + if (typeof aborted !== "boolean") throw invalidOptions(); + return aborted; + } catch { + throw invalidOptions(); + } +} + +function addAbortListener(signal: PreparedAbortSignal, listener: EventListener): void { + try { + Reflect.apply(signal.addEventListener, signal.target, ["abort", listener, { once: true }]); + } catch { + throw invalidOptions(); + } +} + +function removeAbortListener(signal: PreparedAbortSignal, listener: EventListener): void { + try { + Reflect.apply(signal.removeEventListener, signal.target, ["abort", listener]); + } catch { + // Cleanup must never prevent the already-chosen result from settling. + } +} + +function arrayBufferByteLength(value: unknown): number | undefined { + if ( + typeof value !== "object" || + value === null || + ARRAY_BUFFER_BYTE_LENGTH_GETTER === undefined + ) { + return undefined; + } + try { + const byteLength = Reflect.apply(ARRAY_BUFFER_BYTE_LENGTH_GETTER, value, []); + return Number.isSafeInteger(byteLength) && byteLength >= 0 ? byteLength : undefined; + } catch { + // Reject views, SharedArrayBuffer, proxies, and objects spoofing toStringTag. + return undefined; + } +} + +function copyHeaders(value: unknown): Record | undefined { + if (value === undefined) return undefined; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw invalidOptions(); + } + + try { + const headers = Object.create(null) as Record; + for (const [name, headerValue] of Object.entries(value)) { + if (typeof headerValue !== "string") throw invalidOptions(); + headers[name] = headerValue; + } + return headers; + } catch { + throw invalidOptions(); + } +} + +function snapshotOptions(value: unknown): NetworkRequestOptionsSnapshot { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw invalidOptions(); + } + + try { + return { + timeoutMs: Reflect.get(value, "timeoutMs"), + maxRequestBodyBytes: Reflect.get(value, "maxRequestBodyBytes"), + maxResponseBytes: Reflect.get(value, "maxResponseBytes"), + acceptedStatuses: Reflect.get(value, "acceptedStatuses"), + signal: Reflect.get(value, "signal"), + method: Reflect.get(value, "method"), + contentType: Reflect.get(value, "contentType"), + headers: Reflect.get(value, "headers"), + body: Reflect.get(value, "body"), + request: Reflect.get(value, "request"), + }; + } catch { + throw invalidOptions(); + } +} + +function copyAcceptedStatuses(value: unknown): ReadonlySet | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) throw invalidOptions(); + + try { + const length: unknown = Reflect.get(value, "length"); + // There are exactly 500 valid HTTP status codes in the accepted domain. + if (!Number.isSafeInteger(length) || (length as number) < 1 || (length as number) > 500) { + throw invalidOptions(); + } + + const statuses = new Set(); + for (let index = 0; index < (length as number); index += 1) { + const status: unknown = Reflect.get(value, String(index)); + if ( + !Number.isSafeInteger(status) || + (status as number) < 100 || + (status as number) > 599 + ) { + throw invalidOptions(); + } + statuses.add(status as number); + } + return statuses; + } catch { + throw invalidOptions(); + } +} + +function prepareOptions(value: unknown): PreparedNetworkRequestOptions { + try { + const snapshot = snapshotOptions(value); + const timeoutMs = snapshot.timeoutMs ?? DEFAULT_NETWORK_TIMEOUT_MS; + const maxRequestBodyBytes = snapshot.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES; + const maxResponseBytes = snapshot.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES; + + if ( + typeof timeoutMs !== "number" || + !Number.isSafeInteger(timeoutMs) || + timeoutMs < 1 || + timeoutMs > MAX_NETWORK_TIMEOUT_MS || + !isNonNegativeSafeInteger(maxRequestBodyBytes) || + !isNonNegativeSafeInteger(maxResponseBytes) + ) { + throw invalidOptions(); + } + + if (snapshot.method !== undefined && typeof snapshot.method !== "string") { + throw invalidOptions(); + } + if (snapshot.contentType !== undefined && typeof snapshot.contentType !== "string") { + throw invalidOptions(); + } + if (snapshot.request !== undefined && typeof snapshot.request !== "function") { + throw invalidOptions(); + } + + const acceptedStatuses = copyAcceptedStatuses(snapshot.acceptedStatuses); + const signal = prepareAbortSignal(snapshot.signal); + const headers = copyHeaders(snapshot.headers); + const body = validateRequestBody(snapshot.body, maxRequestBodyBytes); + + return { + timeoutMs, + maxRequestBodyBytes, + maxResponseBytes, + acceptedStatuses, + signal, + method: snapshot.method, + contentType: snapshot.contentType, + headers, + body, + request: (snapshot.request as NetworkRequestImplementation | undefined) ?? requestUrl, + }; + } catch (error) { + throw copyRedactedNetworkError(error, "invalid-options"); + } +} + +function boundedUtf8ByteLength(value: string, maximumBytes: number): number | null { + let bytes = 0; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + let added: number; + if (code <= 0x7f) added = 1; + else if (code <= 0x7ff) added = 2; + else if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + added = 4; + index += 1; + } else added = 3; + } else added = 3; + + if (added > maximumBytes - bytes) return null; + bytes += added; + } + return bytes; +} + +function validateRequestBody( + value: unknown, + maximumBytes: number, +): string | ArrayBuffer | undefined { + if (value === undefined) return undefined; + if (typeof value === "string") { + if (boundedUtf8ByteLength(value, maximumBytes) === null) { + throw new NetworkError("request-too-large"); + } + return value; + } + + const bufferBytes = arrayBufferByteLength(value); + if (bufferBytes === undefined) throw invalidOptions(); + if (bufferBytes > maximumBytes) throw new NetworkError("request-too-large"); + return value as ArrayBuffer; +} + +function assertTarget(rawUrl: string): string { + try { + const validated = assertFetchableUrl(rawUrl); + return validated.href; + } catch { + throw new NetworkError("unsafe-target"); + } +} + +function runNativeRequest( + request: NetworkRequestImplementation, + parameters: NetworkRequestParameters, + timeoutMs: number, + signal: PreparedAbortSignal | undefined, ): Promise { - const { timeoutMs = DEFAULT_TIMEOUT_MS, method, headers, body } = options; + return new Promise((resolve, reject) => { + let settled = false; + let timeoutId: number | undefined; + let listenerRegistered = false; - let timeoutId: number | undefined; + const cleanup = () => { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + if (signal && listenerRegistered) removeAbortListener(signal, onAbort); + }; + const rejectOnce = (error: NetworkError) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + const resolveOnce = (response: RequestUrlResponse) => { + if (settled) return; + settled = true; + cleanup(); + resolve(response); + }; + const onAbort = () => rejectOnce(new NetworkError("aborted")); - const timeoutPromise = new Promise((_, reject) => { - timeoutId = window.setTimeout(() => { - reject(new TimeoutError(url, timeoutMs)); - }, timeoutMs); + try { + if (signal) { + listenerRegistered = true; + addAbortListener(signal, onAbort); + if (readAbortState(signal)) onAbort(); + } + if (settled) return; + timeoutId = window.setTimeout(() => rejectOnce(new TimeoutError()), timeoutMs); + } catch { + rejectOnce(invalidOptions()); + return; + } + + let pendingRequest: PromiseLike; + try { + pendingRequest = request(parameters); + } catch (error) { + rejectOnce(copyRedactedNetworkError(error, "transport-failure")); + return; + } + + // requestUrl buffers natively and exposes no cancellation primitive. Always + // attach both handlers so a response or rejection arriving after our logical + // timeout/abort is observed instead of becoming an unhandled rejection. + void Promise.resolve(pendingRequest).then(resolveOnce, () => { + rejectOnce(new NetworkError("transport-failure")); + }); }); +} + +interface CommonResponseSnapshot { + readonly status: number; + readonly arrayBuffer: ArrayBuffer; +} + +interface BinaryResponseFields { + readonly status: unknown; + readonly headers: unknown; + readonly arrayBuffer: unknown; +} + +interface TextResponseFields { + readonly status: unknown; + readonly arrayBuffer: unknown; + readonly text: unknown; +} + +function snapshotBinaryResponseFields(value: object): BinaryResponseFields { + try { + return { + status: Reflect.get(value, "status"), + headers: Reflect.get(value, "headers"), + arrayBuffer: Reflect.get(value, "arrayBuffer"), + }; + } catch { + throw new NetworkError("invalid-response"); + } +} +function snapshotTextResponseFields(value: object): TextResponseFields { try { - const response = await Promise.race([ - requestUrl({ - url, - method, - headers, - body, - throw: false, // Don't throw on non-2xx status - }), - timeoutPromise, - ]); + return { + status: Reflect.get(value, "status"), + arrayBuffer: Reflect.get(value, "arrayBuffer"), + text: Reflect.get(value, "text"), + }; + } catch { + throw new NetworkError("invalid-response"); + } +} + +function createCommonResponseSnapshot( + status: unknown, + arrayBuffer: unknown, +): CommonResponseSnapshot { + if (!Number.isSafeInteger(status) || (status as number) < 100 || (status as number) > 599) { + throw new NetworkError("invalid-response"); + } + return { status: status as number, arrayBuffer: arrayBuffer as ArrayBuffer }; +} - // Check for HTTP errors - if (response.status >= 400) { - throw new NetworkError( - `HTTP ${response.status}: ${response.text?.slice(0, 100) || "Unknown error"}`, - url, - ); +function copyResponseHeaders(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new NetworkError("invalid-response"); + } + + try { + const headers = Object.create(null) as Record; + for (const [name, headerValue] of Object.entries(value)) { + if (typeof headerValue !== "string") { + throw new NetworkError("invalid-response"); + } + headers[name] = headerValue; } + return Object.freeze(headers); + } catch { + throw new NetworkError("invalid-response"); + } +} - return response; +function validateCommonResponse( + response: CommonResponseSnapshot, + maximumBytes: number, + acceptedStatuses: ReadonlySet | undefined, +): void { + const responseBytes = arrayBufferByteLength(response.arrayBuffer); + if (responseBytes === undefined) throw new NetworkError("invalid-response"); + if (responseBytes > maximumBytes) throw new NetworkError("response-too-large"); + + const accepted = acceptedStatuses + ? acceptedStatuses.has(response.status) + : response.status >= 200 && response.status < 300; + if (!accepted) throw new NetworkError("unexpected-status", response.status); +} + +function validateBinaryResponse( + value: unknown, + maximumBytes: number, + acceptedStatuses: ReadonlySet | undefined, +): NetworkBinaryResponse { + try { + if (typeof value !== "object" || value === null) { + throw new NetworkError("invalid-response"); + } + const { status, headers, arrayBuffer } = snapshotBinaryResponseFields(value); + const response = createCommonResponseSnapshot(status, arrayBuffer); + const copiedHeaders = copyResponseHeaders(headers); + validateCommonResponse(response, maximumBytes, acceptedStatuses); + return Object.freeze({ ...response, headers: copiedHeaders }); } catch (error) { - if (error instanceof NetworkError) { - throw error; + throw copyRedactedNetworkError(error, "invalid-response"); + } +} + +function validateTextResponse( + value: unknown, + maximumBytes: number, + acceptedStatuses: ReadonlySet | undefined, +): NetworkTextResponse { + try { + if (typeof value !== "object" || value === null) { + throw new NetworkError("invalid-response"); } - throw new NetworkError(error instanceof Error ? error.message : String(error), url, error); - } finally { - if (timeoutId) { - window.clearTimeout(timeoutId); + const { status, arrayBuffer, text } = snapshotTextResponseFields(value); + if (typeof text !== "string") throw new NetworkError("invalid-response"); + const response = createCommonResponseSnapshot(status, arrayBuffer); + validateCommonResponse(response, maximumBytes, acceptedStatuses); + if (boundedUtf8ByteLength(text, maximumBytes) === null) { + throw new NetworkError("response-too-large"); } + return Object.freeze({ ...response, text }); + } catch (error) { + throw copyRedactedNetworkError(error, "invalid-response"); } } /** - * Fetches JSON from a URL with timeout protection. + * Bounded, redacted boundary around Obsidian's CORS-free `requestUrl` transport. + * + * `requestUrl` does not expose redirect hops, the final URL, DNS answers, the + * connected peer, native cancellation, or an incremental response stream. A + * timeout or abort therefore settles only this logical operation: the native + * request may finish later and its fully buffered response is observed and + * discarded. The response byte ceiling is enforced immediately after that + * native buffering completes, so it bounds consumers but cannot cap native + * allocation while bytes are in flight. */ +async function requestProjected( + url: string, + options: NetworkRequestOptions, + project: ( + value: unknown, + maximumBytes: number, + acceptedStatuses: ReadonlySet | undefined, + ) => T, +): Promise { + const prepared = prepareOptions(options); + const parameters: NetworkRequestParameters = { + method: prepared.method, + contentType: prepared.contentType, + headers: prepared.headers, + body: prepared.body, + throw: false, + // Keep the target gate as the final validation before listener registration. + url: assertTarget(url), + }; + const response = await runNativeRequest( + prepared.request, + parameters, + prepared.timeoutMs, + prepared.signal, + ); + return project(response, prepared.maxResponseBytes, prepared.acceptedStatuses); +} + +/** Fetch a stable binary response without touching eager text or JSON accessors. */ +export async function requestWithTimeout( + url: string, + options: NetworkRequestOptions = {}, +): Promise { + return requestProjected(url, options, validateBinaryResponse); +} + +/** Fetch and decode JSON from the stable text projection. */ export async function fetchJsonWithTimeout( url: string, - options: { timeoutMs?: number } = {}, + options: NetworkRequestOptions = {}, ): Promise { - const response = await requestWithTimeout(url, options); - return response.json as T; + const response = await requestProjected(url, options, validateTextResponse); + try { + return JSON.parse(response.text) as T; + } catch { + throw new NetworkError("invalid-response"); + } } -/** - * Fetches text from a URL with timeout protection. - */ +/** Fetch text through the bounded request boundary. */ export async function fetchTextWithTimeout( url: string, - options: { timeoutMs?: number } = {}, + options: NetworkRequestOptions = {}, ): Promise { - const response = await requestWithTimeout(url, options); - return response.text; + return (await requestProjected(url, options, validateTextResponse)).text; } diff --git a/tests/fixtures/network/mobile-feed.xml b/tests/fixtures/network/mobile-feed.xml new file mode 100644 index 0000000..4572105 --- /dev/null +++ b/tests/fixtures/network/mobile-feed.xml @@ -0,0 +1,22 @@ + + + + PodNotes Mobile Network Gate Fixture + https://example.com/podnotes-mobile-network-gate + Deterministic feed used only for real-device PodNotes network verification. + + + Mobile URL Boundary Episode + https://example.com/podnotes-mobile-network-gate/episode + Confirms that the mobile feed retrieval and parsing path completed. + podnotes-mobile-network-gate-episode-1 + Wed, 01 Jul 2026 12:00:00 GMT + + 1 + + + diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index 1e92c23..6838da3 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -36,6 +36,16 @@ export class Notice { hide(): void {} } +export const normalizePath = (path: string): string => { + const normalized: string[] = []; + for (const segment of path.replaceAll("\\", "/").split("/")) { + if (!segment || segment === ".") continue; + if (segment === "..") normalized.pop(); + else normalized.push(segment); + } + return normalized.join("/"); +}; + export class WorkspaceLeaf {} export class ItemView {} @@ -68,6 +78,7 @@ export class Modal { } export class SuggestModal extends Modal { + declare protected readonly itemType?: T; setPlaceholder(_placeholder: string): void {} } @@ -382,8 +393,11 @@ export const setIcon = (el: HTMLElement | null, icon: string) => { }; export const requestUrl = async () => ({ + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), text: "", - json: async () => ({}), + json: {}, }); export const htmlToMarkdown = (value: string) => value;