Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/URIHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,33 @@ describe("podNotesURIHandler", () => {
expect(get(viewState)).toBe(ViewState.PodcastGrid);
});

test.each([
"http://169.254.169.254/latest/meta-data/",
"http://127.0.0.1:8080/feed.xml",
"http://192.168.0.1/feed.xml",
])(
"refuses a deep link whose url points at an internal host (%s) without fetching",
async (url) => {
const revealPlayer = vi.fn();

await podNotesURIHandler(
{
action: "podnotes",
url,
episodeName: "Some Episode",
},
api as never,
revealPlayer,
);

// The attacker-controlled url is never handed to the feed parser.
expect(mockGetEpisodes).not.toHaveBeenCalled();
expect(get(currentEpisode)).toBeUndefined();
expect(get(viewState)).toBe(ViewState.PodcastGrid);
expect(revealPlayer).not.toHaveBeenCalled();
},
);

test("keeps the requested segment end for the player to apply after loading metadata", async () => {
await podNotesURIHandler(
{
Expand Down
12 changes: 12 additions & 0 deletions src/URIHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
viewState,
} from "./store";
import type { Episode } from "./types/Episode";
import { isFetchableUrl } from "./utility/assertFetchableUrl";
import { getEpisodeKey } from "./utility/episodeKey";
import { ViewState } from "./types/ViewState";

Expand Down Expand Up @@ -178,6 +179,17 @@ export default async function podNotesURIHandler(
.map((name) => localFiles.getLocalEpisode(name))
.find((ep) => ep !== undefined);
} else {
// The url here came straight from an untrusted obsidian://podnotes deep link
// (an attacker can put one behind <a href> on a web page), so a single click
// must not be able to fetch an arbitrary internal host. Refuse anything that
// isn't a public http(s) URL before handing it to FeedParser (blind SSRF).
if (!isFetchableUrl(url)) {
new Notice(
"Refusing to load a feed from a private, local, or non-http(s) URL",
);
return;
}

try {
// Fetch with the raw url (current-format links are correct as-is); only the title gets
// the legacy-candidate treatment. A '+' in a legacy feed URL is pre-existing and out of
Expand Down
83 changes: 83 additions & 0 deletions src/download/streaming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,89 @@ describe("writeStreamedFile", () => {
});
});

describe("download size cap (resource exhaustion)", () => {
it("rejects a 206 whose advertised total exceeds the cap, up front", async () => {
requestUrlMock.mockResolvedValue(
res(206, [1, 2, 3, 4], {
"content-type": "audio/mpeg",
"content-range": "bytes 0-3/1099511627776", // 1 TiB
}),
);

await expect(
probeAndFetchFirstChunk("https://x/ep.mp3", 4, 100),
).rejects.toThrow(/maximum allowed size/);
});

it("rejects a 200 fallback whose whole body exceeds the cap", async () => {
// Server ignores Range and returns the entire (oversized) body in one 200.
requestUrlMock.mockResolvedValue(
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(/maximum allowed size/);
});

it("aborts the 206 loop once the running total exceeds the cap (unknown-total infinite stream)", async () => {
const a = setupAdapter();
// A malicious server with an unknown total ("/*") that returns full-size
// chunks forever — only the running-total cap can stop it.
requestUrlMock.mockResolvedValue(res(206, [9, 9]));

await expect(
writeStreamedFile(
"https://x/ep.mp3",
"out.mp3",
probe({ totalSize: null, firstChunk: new Uint8Array([1, 2]).buffer }),
undefined,
2, // chunkSize
5, // maxSize
),
).rejects.toThrow(/maximum allowed size/);

// It stopped instead of writing without bound.
expect((a.writes.get("out.mp3")?.length ?? 0)).toBeLessThanOrEqual(5 + 2);
});

it("rejects when even the first chunk already exceeds the cap", async () => {
const a = setupAdapter();

await expect(
writeStreamedFile(
"https://x/ep.mp3",
"out.mp3",
probe({ firstChunk: new Uint8Array([1, 2, 3, 4, 5, 6]).buffer, supportsRange: false }),
undefined,
2,
5,
),
).rejects.toThrow(/maximum allowed size/);
expect(a.writeBinary).not.toHaveBeenCalled();
});
});

describe("SSRF guard", () => {
it.each([
"http://169.254.169.254/latest/meta-data/",
"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/);
expect(requestUrlMock).not.toHaveBeenCalled();
});

it("writeStreamedFile refuses a blocked URL before touching the adapter", async () => {
const a = setupAdapter();
await expect(
writeStreamedFile("http://192.168.0.1/ep.mp3", "out.mp3", probe(), undefined, 2),
).rejects.toThrow(/Refusing/);
expect(a.writeBinary).not.toHaveBeenCalled();
expect(requestUrlMock).not.toHaveBeenCalled();
});
});

describe("partialPathFor / isPartialPath", () => {
it("builds a dot-prefixed sibling temp in the same folder", () => {
const tmp = partialPathFor("Podcasts/Show/Ep 1.mp3");
Expand Down
45 changes: 45 additions & 0 deletions src/download/streaming.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type DataAdapter, requestUrl } 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";

Expand All @@ -25,6 +26,23 @@ import { enforceMaxPathLength } from "../utility/enforceMaxPathLength";

export const DOWNLOAD_CHUNK_SIZE = 4 * 1024 * 1024; // 4 MiB per range request

// Total-bytes ceiling for a single download. The per-chunk bound above keeps
// peak memory flat, but without a TOTAL cap a malicious media server (the host of
// a feed's enclosure URL is attacker-controlled) can fill the disk: it can answer
// 200 with an enormous body, advertise an arbitrarily large Content-Range total,
// or - with an unknown total ("bytes 0-N/*") - return full-size 206 chunks forever
// so the append loop never terminates. 2 GiB clears any real podcast episode
// (long-form audio is ~hundreds of MB; even large video episodes fit) while
// turning the unbounded write into a bounded, recoverable failure.
export const MAX_DOWNLOAD_SIZE = 2 * 1024 * 1024 * 1024; // 2 GiB

function tooLargeError(maxSize: number): Error {
const maxMb = Math.round(maxSize / (1024 * 1024));
return new Error(
`Download exceeds the maximum allowed size (${maxMb} MB). Aborting.`,
);
}

// Obsidian's DataAdapter — writeBinary/rename/remove/list, all used below — is
// fully typed and public. Only `appendBinary` exists at runtime on the desktop and
// mobile Capacitor adapters without appearing in the public typings, so we extend
Expand Down Expand Up @@ -69,7 +87,9 @@ function readHeader(
export async function probeAndFetchFirstChunk(
url: string,
chunkSize: number = DOWNLOAD_CHUNK_SIZE,
maxSize: number = MAX_DOWNLOAD_SIZE,
): Promise<RangeProbe> {
assertFetchableUrl(url);
const encodedUrl = encodeUrlForRequest(url);
const response = await requestUrl({
url: encodedUrl,
Expand Down Expand Up @@ -104,6 +124,17 @@ export async function probeAndFetchFirstChunk(
}
}

// Reject an oversized download up front: a known total over the cap, or a 200
// fallback whose whole body (requestUrl has already buffered it) is over the
// cap. The unknown-total 206 case can't be caught here and is bounded by the
// running-total check in writeStreamedFile instead.
if (totalSize !== null && totalSize > maxSize) {
throw tooLargeError(maxSize);
}
if (!supportsRange && response.arrayBuffer.byteLength > maxSize) {
throw tooLargeError(maxSize);
}

return {
firstChunk: response.arrayBuffer,
contentType,
Expand All @@ -123,9 +154,15 @@ export async function writeStreamedFile(
probe: RangeProbe,
onProgress?: (written: number, total: number | null) => void,
chunkSize: number = DOWNLOAD_CHUNK_SIZE,
maxSize: number = MAX_DOWNLOAD_SIZE,
): Promise<number> {
assertFetchableUrl(url);
const adapter = appendableAdapter();

if (probe.firstChunk.byteLength > maxSize) {
throw tooLargeError(maxSize);
}

await adapter.writeBinary(destPath, probe.firstChunk);
let written = probe.firstChunk.byteLength;
onProgress?.(written, probe.totalSize);
Expand Down Expand Up @@ -166,6 +203,14 @@ export async function writeStreamedFile(
written += chunk.byteLength;
onProgress?.(written, probe.totalSize);

// Hard ceiling on the total written. This is the only stop for a server
// that advertises an unknown total ("bytes 0-N/*") and returns full-size
// 206 chunks forever — without it the loop would append to disk until the
// disk fills. The caller's finally drops the (now over-cap) temp file.
if (written > maxSize) {
throw tooLargeError(maxSize);
}

// Unknown total: a short chunk means we hit EOF.
if (probe.totalSize === null && chunk.byteLength < chunkSize) break;
}
Expand Down
17 changes: 17 additions & 0 deletions src/downloadEpisode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1225,6 +1225,23 @@ describe("getEpisodeAudioBuffer (issue #107)", () => {
expect(result.basename).toBe("recording");
expect(requestUrlMock).not.toHaveBeenCalled();
});

it.each([
"file:///Users/victim/.ssh/id_rsa",
"http://169.254.169.254/latest/meta-data/",
"http://127.0.0.1:9200/_search",
])(
"refuses to fetch a feed-controlled stream URL pointing at %s (SSRF/exfil guard)",
async (streamUrl) => {
const ssrf = episode({
title: "Malicious Enclosure",
streamUrl,
});

await expect(getEpisodeAudioBuffer(ssrf)).rejects.toThrow(/Refusing/);
expect(requestUrlMock).not.toHaveBeenCalled();
},
);
});

describe("downloadEpisodeWithNotice (streaming range path)", () => {
Expand Down
3 changes: 3 additions & 0 deletions src/downloadEpisode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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";
Expand Down Expand Up @@ -53,6 +54,7 @@ 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<DownloadedFile> {
assertFetchableUrl(url);
const encodedUrl = encodeUrlForRequest(url);
try {
const response = await requestUrl({ url: encodedUrl, method: "GET" });
Expand Down Expand Up @@ -708,6 +710,7 @@ export async function downloadEpisode(
}

async function getFileExtension(url: string): Promise<string> {
assertFetchableUrl(url);
const encodedUrl = encodeUrlForRequest(url);
const urlExtension = getUrlExtension(encodedUrl);
if (urlExtension) return urlExtension;
Expand Down
98 changes: 98 additions & 0 deletions src/utility/assertFetchableUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import {
assertFetchableUrl,
isFetchableUrl,
UnsafeFetchUrlError,
} from "./assertFetchableUrl";

describe("assertFetchableUrl", () => {
it("accepts ordinary public http(s) feed/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.each([
"file:///Users/victim/.ssh/id_rsa",
"data:text/plain,hello",
"blob:https://example.com/uuid",
"ftp://example.com/file",
"javascript:alert(1)",
])("rejects non-http(s) scheme %s", (url) => {
expect(() => assertFetchableUrl(url)).toThrow(UnsafeFetchUrlError);
});

it.each([
"",
" ",
"not a url",
"//example.com/no-scheme",
])("rejects empty/malformed url %s", (url) => {
expect(() => assertFetchableUrl(url)).toThrow(UnsafeFetchUrlError);
});

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://sub.localhost./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://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);
});

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);
});

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);
});

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) => {
expect(() => assertFetchableUrl(url)).not.toThrow();
});

it("exposes a non-throwing predicate", () => {
expect(isFetchableUrl("https://example.com/a.mp3")).toBe(true);
expect(isFetchableUrl("http://169.254.169.254/")).toBe(false);
expect(isFetchableUrl("file:///etc/passwd")).toBe(false);
});
});
Loading
Loading