Skip to content
Open
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
7 changes: 6 additions & 1 deletion .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,13 @@
**Learning:** Checking for string prefixes on URLs is insufficient for security because the userinfo component (e.g. `https://discord.com/api/webhooks/@127.0.0.1`) can be used to bypass the check. The `fetch` API and URL parsers will interpret `127.0.0.1` as the hostname and `discord.com` as the credentials.
**Prevention:** Never rely on string prefix matching for URL validation. Always use the project's `safeFetch` utility which properly resolves and validates the target IP address to prevent SSRF and DNS rebinding attacks.

## $(date +%Y-%m-%d) - Prevent SSRF by validating parsed URL components instead of prefix matching
## 2025-05-24 - Prevent SSRF by validating parsed URL components instead of prefix matching

**Vulnerability:** The application validated Discord webhook URLs (and potentially other external URLs) by matching the string prefix (`url.startsWith("https://discord.com")`). This is vulnerable to SSRF bypasses via the userinfo component (e.g., `https://discord.com@127.0.0.1/api/webhooks/`).
**Learning:** Checking string prefixes for URL validation is fundamentally insecure because parts of the prefix might be interpreted as the username/password in a URL with a different domain. Attackers can leverage this to bypass domain allowlists.
**Prevention:** Always use the `URL` object (e.g., `new URL()`) to parse URLs and explicitly validate the `hostname` and `pathname` properties instead of checking raw string prefixes.

## 2025-05-24 - SSRF Prevention in Internal Downstream Requests

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ“ Maintainability & Code Quality | 🟑 Minor | ⚑ Quick win

Add a blank line after the heading.

Markdownlint reports MD022 because Line [31] is immediately followed by the paragraph on Line [32]. Insert one blank line.

🧰 Tools
πŸͺ› markdownlint-cli2 (0.23.2)

[warning] 31-31: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)

πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.jules/sentinel.md at line 31, Insert one blank line immediately after the
β€œ2025-05-24 - SSRF Prevention in Internal Downstream Requests” heading in the
changelog section, before its following paragraph.

Source: Linters/SAST tools

**Vulnerability:** Downloader API communication (e.g. qBittorrent, Transmission, NZBGet, Deluge, rTorrent) was using the native `fetch()` to call external URLs (e.g. downloaders running locally or remotely), making the application vulnerable to Server-Side Request Forgery (SSRF) and DNS Rebinding via metadata or local networks.
**Learning:** Even internal API connections must be shielded from interacting with sensitive hostnames or local/cloud metadata networks (169.254.169.254, etc.) via DNS rebinding. Replacing native `fetch` with a secure wrapper ensures strict host-level validation of all connections.
**Prevention:** Always use the local `safeFetch` implementation from `server/ssrf.ts` instead of the global `fetch()` when making external network requests to any user-provided URL or API configuration to prevent SSRF vulnerabilities.
1 change: 1 addition & 0 deletions server/__tests__/downloaders_deluge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ vi.mock("../logger.js", () => ({

vi.mock("../ssrf.js", () => ({
isSafeUrl: vi.fn().mockResolvedValue(true),
safeFetch: vi.fn((url: string, options: RequestInit) => fetch(url, options)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ“ Maintainability & Code Quality | 🟠 Major | πŸ—οΈ Heavy lift

Centralize the repeated SSRF test setup. The same mock is defined in six files. Move it to tests/setup.ts, and retain only test-specific overrides locally.

  • server/__tests__/downloaders_deluge.test.ts#L22-L22: move the safeFetch mock to shared setup.
  • server/__tests__/downloaders_deluge_coverage.test.ts#L22-L22: move the safeFetch mock to shared setup.
  • server/__tests__/downloaders_deluge_remaining.test.ts#L21-L24: move the isSafeUrl and safeFetch mocks to shared setup.
  • server/__tests__/downloaders_rtorrent_remaining.test.ts#L25-L25: move the safeFetch mock to shared setup.
  • server/__tests__/downloaders_transmission_remaining.test.ts#L24-L24: move the safeFetch mock to shared setup.
  • server/__tests__/routes.test.ts#L4-L7: move the isSafeUrl and safeFetch mocks to shared setup.

As per coding guidelines, shared test setup must use tests/setup.ts.

πŸ“ Affects 6 files
  • server/__tests__/downloaders_deluge.test.ts#L22-L22 (this comment)
  • server/__tests__/downloaders_deluge_coverage.test.ts#L22-L22
  • server/__tests__/downloaders_deluge_remaining.test.ts#L21-L24
  • server/__tests__/downloaders_rtorrent_remaining.test.ts#L25-L25
  • server/__tests__/downloaders_transmission_remaining.test.ts#L24-L24
  • server/__tests__/routes.test.ts#L4-L7
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/__tests__/downloaders_deluge.test.ts` at line 22, Centralize the
shared SSRF test mocks in tests/setup.ts: move the isSafeUrl and safeFetch mock
definitions there, then remove them from
server/__tests__/downloaders_deluge.test.ts#L22-L22,
server/__tests__/downloaders_deluge_coverage.test.ts#L22-L22,
server/__tests__/downloaders_deluge_remaining.test.ts#L21-L24,
server/__tests__/downloaders_rtorrent_remaining.test.ts#L25-L25,
server/__tests__/downloaders_transmission_remaining.test.ts#L24-L24, and
server/__tests__/routes.test.ts#L4-L7. Retain only test-specific local overrides
in each file.

Source: Coding guidelines


πŸ”’ Security & Privacy | πŸ”΅ Trivial | πŸ—οΈ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rtk rg -n 'safeFetch|isSafeUrl|DNS rebinding|private|redirect|timeout' server --glob '*.test.ts'

Repository: Doezer/Questarr

Length of output: 194


🏁 Script executed:

#!/bin/bash
set -u

echo "== repository files around tests/implementation =="
git ls-files | rg '(^|/)server/(ssrf\.ts|downloaders/.*\.ts|__tests__/.*\.test\.ts|routes\.test\.ts)$' | sed -n '1,120p'

echo ""
echo "== search SSRF terms in tracked server tests without rtk =="
rg -n 'safeFetch|isSafeUrl|DNS rebinding|private|redirect|timeout' server --glob '*.test.ts' || true

echo ""
echo "== relevant test imports/mock snippets =="
for f in \
  server/__tests__/downloaders_deluge.test.ts \
  server/__tests__/downloaders_deluge_coverage.test.ts \
  server/__tests__/downloaders_deluge_remaining.test.ts \
  server/__tests__/downloaders_rtorrent_remaining.test.ts \
  server/__tests__/downloaders_transmission_remaining.test.ts \
  server/__tests__/routes.test.ts
do
  if [ -f "$f" ]; then
    echo "--- $f ($(wc -l < "$f") lines) ---"
    sed -n '1,80p' "$f"
  fi
done

echo ""
echo "== ssrf implementation outline and content =="
wc -l server/ssrf.ts
sed -n '1,260p' server/ssrf.ts

Repository: Doezer/Questarr

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -u

echo "== targeted test snippets =="
for f in \
  server/__tests__/downloaders_deluge.test.ts \
  server/__tests__/downloaders_deluge_coverage.test.ts \
  server/__tests__/downloaders_deluge_remaining.test.ts \
  server/__tests__/downloaders_rtorrent_remaining.test.ts \
  server/__tests__/downloaders_transmission_remaining.test.ts \
  server/__tests__/routes.test.ts
do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    sed -n '1,120p' "$f"
  fi
done

echo ""
echo "== targeted ssrf implementation =="
sed -n '1,240p' server/ssrf.ts

echo ""
echo "== targeted existing SSRF tests snippets =="
for f in server/__tests__/ssrf.test.ts server/__tests__/downloaders_ssrf.test.ts server/__tests__/rss-ssrf.test.ts server/__tests__/ssrf_routes.test.ts; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    sed -n '1,220p' "$f"
  fi
done

echo ""
echo "== safeFetch call sites in listed targeted files =="
python3 - <<'PY'
from pathlib import Path
paths = [
 "server/__tests__/downloaders_deluge.test.ts",
 "server/__tests__/downloaders_deluge_coverage.test.ts",
 "server/__tests__/downloaders_deluge_remaining.test.ts",
 "server/__tests__/downloaders_rtorrent_remaining.test.ts",
 "server/__tests__/downloaders_transmission_remaining.test.ts",
 "server/__tests__/routes.test.ts",
 "server/ssrf.ts",
]
for p in paths:
    text = Path(p).read_text()
    print(f"--- {p} contains safeFetch/isSafeUrl/redirect/timeout/private/DNS occurrences ---")
    for i, line in enumerate(text.splitlines(), 1):
        lower = line.lower()
        if any(x in lower for x in ["safefetch", "issafeurl", "redirect", "timeout", "private", "dns", "metadata", "rebind"]):
            print(f"{i}: {line}")
PY

Repository: Doezer/Questarr

Length of output: 50371


Other (CWE-693)

Reachability path
● Entry
  server/__tests__/downloaders_deluge.test.ts
β”‚
β–Ό
● Sink
  server/downloaders/deluge.ts

Cover safeFetch controls outside pass-through mock tests. The listed tests replace safeFetch with native fetch, so they no longer test URL validation, redirect revalidation, DNS-rebinding checks, or timeouts for those paths. Add coverage in tests that use the real wrapper for Deluge, rTorrent, Transmission, and route-level private/unsafe-redirect rejection.

πŸ“ Affects 6 files
  • server/__tests__/downloaders_deluge.test.ts#L22-L22 (this comment)
  • server/__tests__/downloaders_deluge_coverage.test.ts#L22-L22
  • server/__tests__/downloaders_deluge_remaining.test.ts#L21-L24
  • server/__tests__/downloaders_rtorrent_remaining.test.ts#L25-L25
  • server/__tests__/downloaders_transmission_remaining.test.ts#L24-L24
  • server/__tests__/routes.test.ts#L4-L7
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/__tests__/downloaders_deluge.test.ts` at line 22, Pass-through
safeFetch mocks bypass wrapper protections, leaving validation, redirect
revalidation, DNS-rebinding, timeout, and private/unsafe-redirect behavior
untested. In server/__tests__/downloaders_deluge.test.ts:22,
server/__tests__/downloaders_deluge_coverage.test.ts:22,
server/__tests__/downloaders_deluge_remaining.test.ts:21-24,
server/__tests__/downloaders_rtorrent_remaining.test.ts:25, and
server/__tests__/downloaders_transmission_remaining.test.ts:24, add coverage
using the real safeFetch wrapper for the respective downloader paths. In
server/__tests__/routes.test.ts:4-7, add route-level coverage for private and
unsafe-redirect rejection; retain pass-through mocks only where wrapper controls
are not under test.

}));

vi.mock("../downloaders/utils.js", async (importOriginal) => {
Expand Down
1 change: 1 addition & 0 deletions server/__tests__/downloaders_deluge_coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ vi.mock("../logger.js", () => ({

vi.mock("../ssrf.js", () => ({
isSafeUrl: vi.fn().mockResolvedValue(true),
safeFetch: vi.fn((url: string, options: RequestInit) => fetch(url, options)),
}));

vi.mock("../downloaders/utils.js", async (importOriginal) => {
Expand Down
4 changes: 4 additions & 0 deletions server/__tests__/downloaders_deluge_remaining.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ vi.mock("../logger.js", () => ({
}));

global.fetch = fetchMock as unknown as typeof fetch;
vi.mock("../ssrf.js", () => ({
isSafeUrl: vi.fn().mockResolvedValue(true),
safeFetch: vi.fn((url: string, options?: RequestInit) => global.fetch(url, options)),
}));

const { DelugeClient } = await import("../downloaders/deluge.js");

Expand Down
5 changes: 5 additions & 0 deletions server/__tests__/downloaders_helpers_regression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ vi.mock("../logger.js", () => ({

const fetchMock = vi.fn();
global.fetch = fetchMock as unknown as typeof fetch;
vi.mock("../ssrf.js", () => ({
isSafeUrl: vi.fn().mockResolvedValue(true),
safeFetch: vi.fn((url: string, options?: RequestInit) => global.fetch(url, options)),
resolveSafeAddress: vi.fn().mockResolvedValue({ address: "127.0.0.1", family: 4 }),
}));
Comment on lines +31 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | πŸ”΅ Trivial | ⚑ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rtk rg -n -C 6 'safeFetch|global\.fetch|fetchMock|isSafeUrl' server/__tests__

Repository: Doezer/Questarr

Length of output: 194


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "repo files matching test target:"
git ls-files 'server/__tests__/*' | sed -n '1,200p'

echo
echo "security/ssrf.ts:"
if [ -f server/ssrf.ts ]; then wc -l server/ssrf.ts; sed -n '1,240p' server/ssrf.ts; fi

echo
echo "mocks and safeFetch references in tests (without rtk):"
rg -n -C 4 'safeFetch|global\.fetch|fetchMock|isSafeUrl|resolveSafeAddress' server/__tests__ || true

Repository: Doezer/Questarr

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "ssrf.ts:"
if [ -f server/ssrf.ts ]; then
  wc -l server/ssrf.ts
  sed -n '1,260p' server/ssrf.ts
fi

echo
echo "focused test files:"
for f in server/__tests__/downloaders_helpers_regression.test.ts server/__tests__/downloaders_nzbget_remaining.test.ts server/__tests__/downloaders_qbittorrent_remaining.test.ts; do
  if [ -f "$f" ]; then
    echo "--- $f"
    wc -l "$f"
    sed -n '1,220p' "$f"
  fi
done

echo
echo "focused safeFetch/imported test assertions:"
python3 - <<'PY'
from pathlib import Path
files = [
  Path("server/__tests__/downloaders_helpers_regression.test.ts"),
  Path("server/__tests__/downloaders_nzbget_remaining.test.ts"),
  Path("server/__tests__/downloaders_qbittorrent_remaining.test.ts"),
]
for p in files:
    if not p.exists():
        continue
    text = p.read_text()
    print(f"{p}: safeFetch_imported={'.safeFetch' in text}; safeFetch_expected={text.count('safeFetch')}")
PY

Repository: Doezer/Questarr

Length of output: 29698


Assert safeFetch usage in these test mocks.

The mock aliases let regressions to native fetch still pass. In these files, import safeFetch where used and verify the downloader path runs through it instead of relying only on global.fetch/fetchMock. Also cover private URL and redirect checks separately.

πŸ“ Affects 3 files
  • server/__tests__/downloaders_helpers_regression.test.ts#L31-L35 (this comment)
  • server/__tests__/downloaders_nzbget_remaining.test.ts#L17-L17
  • server/__tests__/downloaders_qbittorrent_remaining.test.ts#L24-L24
πŸ€– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/__tests__/downloaders_helpers_regression.test.ts` around lines 31 -
35, Update the SSRF mocks and downloader assertions in
server/__tests__/downloaders_helpers_regression.test.ts (lines 31-35),
server/__tests__/downloaders_nzbget_remaining.test.ts (line 17), and
server/__tests__/downloaders_qbittorrent_remaining.test.ts (line 24): import and
spy on safeFetch, assert each downloader invokes it rather than only
global.fetch or fetchMock, and add separate coverage for private-URL and
redirect validation paths.


const createDownloader = (overrides: Partial<Downloader> = {}): Downloader => {
const now = new Date("2024-01-01T00:00:00.000Z");
Expand Down
2 changes: 1 addition & 1 deletion server/__tests__/downloaders_nzbget_remaining.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ vi.mock("../logger.js", () => ({

vi.mock("../ssrf.js", () => ({
isSafeUrl: vi.fn(),
safeFetch: vi.fn(),
safeFetch: vi.fn((url: string, options: RequestInit) => fetch(url, options)),
}));

const { isSafeUrl, safeFetch } = await import("../ssrf.js");
Expand Down
2 changes: 1 addition & 1 deletion server/__tests__/downloaders_qbittorrent_remaining.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ vi.mock("../logger.js", () => ({

vi.mock("../ssrf.js", () => ({
isSafeUrl: vi.fn().mockResolvedValue(true),
safeFetch: vi.fn(),
safeFetch: vi.fn((url: string, options?: RequestInit) => global.fetch(url, options)),
}));

vi.mock("../downloaders/utils.js", async (importOriginal) => {
Expand Down
1 change: 1 addition & 0 deletions server/__tests__/downloaders_rtorrent_remaining.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ vi.mock("../logger.js", () => ({

vi.mock("../ssrf.js", () => ({
isSafeUrl: vi.fn().mockResolvedValue(true),
safeFetch: vi.fn((url: string, options: RequestInit) => fetch(url, options)),
}));

vi.mock("../downloaders/utils.js", async (importOriginal) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ vi.mock("../logger.js", () => ({

vi.mock("../ssrf.js", () => ({
isSafeUrl: vi.fn().mockResolvedValue(true),
safeFetch: vi.fn((url: string, options: RequestInit) => fetch(url, options)),
}));

vi.mock("../downloaders/utils.js", async (importOriginal) => {
Expand Down
4 changes: 4 additions & 0 deletions server/__tests__/routes.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Downloader } from "../../shared/schema";
import { DownloaderManager } from "../downloaders.js";
vi.mock("../ssrf.js", () => ({
isSafeUrl: vi.fn().mockResolvedValue(true),
safeFetch: vi.fn((url: string, options?: RequestInit) => global.fetch(url, options)),
}));

describe("/api/downloads endpoint", () => {
let fetchMock: ReturnType<typeof vi.fn>;
Expand Down
4 changes: 2 additions & 2 deletions server/downloaders/deluge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {
DownloadDetails,
} from "../../shared/schema.js";
import { downloadersLogger } from "../logger.js";
import { isSafeUrl } from "../ssrf.js";
import { isSafeUrl, safeFetch } from "../ssrf.js";
import type { DownloadRequest, DownloaderClient } from "./types.js";
import { fetchWithMagnetDetection, extractHashFromUrl } from "./utils.js";
import { z } from "zod";
Expand Down Expand Up @@ -767,7 +767,7 @@ export class DelugeClient implements DownloaderClient {
headers["Cookie"] = this.cookie;
}

const response = await fetch(url, {
const response = await safeFetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
Expand Down
2 changes: 1 addition & 1 deletion server/downloaders/nzbget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ export class NZBGetClient implements DownloaderClient {

downloadersLogger.debug({ url, method, params: logParams }, "Making NZBGet XML-RPC request");

const response = await fetch(url, {
const response = await safeFetch(url, {
method: "POST",
headers,
body: xmlBody,
Expand Down
8 changes: 4 additions & 4 deletions server/downloaders/qbittorrent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {
import { downloadersLogger } from "../logger.js";
import { randomUUID } from "node:crypto";
import parseTorrent from "parse-torrent";
import { isSafeUrl } from "../ssrf.js";
import { isSafeUrl, safeFetch } from "../ssrf.js";
import type { DownloadRequest, DownloaderClient } from "./types.js";
import { fetchWithMagnetDetection, extractHashFromUrl, fixNzbUrlEncoding } from "./utils.js";

Expand Down Expand Up @@ -1189,7 +1189,7 @@ export class QBittorrentClient implements DownloaderClient {
formData.append("password", this.downloader.password);

try {
const response = await fetch(url, {
const response = await safeFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Expand Down Expand Up @@ -1339,7 +1339,7 @@ export class QBittorrentClient implements DownloaderClient {
"Making qBittorrent request"
);

let response = await fetch(url, {
let response = await safeFetch(url, {
method,
headers,
body: requestBody,
Expand All @@ -1358,7 +1358,7 @@ export class QBittorrentClient implements DownloaderClient {
retryHeaders["Cookie"] = this.cookie;
}

response = await fetch(url, {
response = await safeFetch(url, {
method,
headers: retryHeaders,
body: requestBody,
Expand Down
6 changes: 3 additions & 3 deletions server/downloaders/rtorrent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {
import { downloadersLogger } from "../logger.js";
import parseTorrent from "parse-torrent";
import crypto from "crypto";
import { isSafeUrl } from "../ssrf.js";
import { isSafeUrl, safeFetch } from "../ssrf.js";
import type { DownloadRequest, DownloaderClient, XMLValue } from "./types.js";
import { fetchWithMagnetDetection, extractHashFromUrl } from "./utils.js";
import { XMLParser } from "fast-xml-parser";
Expand Down Expand Up @@ -711,7 +711,7 @@ export class RTorrentClient implements DownloaderClient {
headers["Authorization"] = `Basic ${auth}`;
}

const response = await fetch(url, {
const response = await safeFetch(url, {
method: "POST",
headers,
body: xmlBody,
Expand Down Expand Up @@ -744,7 +744,7 @@ export class RTorrentClient implements DownloaderClient {

downloadersLogger.debug({ url }, "Retrying rTorrent request with Digest Auth");

const retryResponse = await fetch(url, {
const retryResponse = await safeFetch(url, {
method: "POST",
headers,
body: xmlBody,
Expand Down
6 changes: 3 additions & 3 deletions server/downloaders/transmission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
} from "../../shared/schema.js";
import { downloadersLogger } from "../logger.js";
import parseTorrent from "parse-torrent";
import { isSafeUrl } from "../ssrf.js";
import { isSafeUrl, safeFetch } from "../ssrf.js";
import type { DownloadRequest, DownloaderClient } from "./types.js";
import { fetchWithMagnetDetection } from "./utils.js";

Expand Down Expand Up @@ -699,7 +699,7 @@ export class TransmissionClient implements DownloaderClient {
headers["Authorization"] = `Basic ${auth}`;
}

const response = await fetch(url, {
const response = await safeFetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
Expand All @@ -716,7 +716,7 @@ export class TransmissionClient implements DownloaderClient {
downloadersLogger.debug({ method, url }, "Retrying Transmission request with session ID");

// Retry with session ID
const retryResponse = await fetch(url, {
const retryResponse = await safeFetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
Expand Down