Skip to content

backport: harden outbound fetches on release/1.4.0#642

Open
Doezer wants to merge 1 commit into
release/1.4.0from
backport/release-1.4.0-ssrf-fix
Open

backport: harden outbound fetches on release/1.4.0#642
Doezer wants to merge 1 commit into
release/1.4.0from
backport/release-1.4.0-ssrf-fix

Conversation

@Doezer
Copy link
Copy Markdown
Owner

@Doezer Doezer commented May 27, 2026

Summary

Review notes

Backport the useful security changes from PR #640 to release/1.4.0.

Address the review follow-ups by preserving bracketed IPv6 literal support in safeFetch and pinning SABnzbd insecure SSL fallback requests to the resolved IP to avoid DNS rebinding.

PR #639 was reviewed but not backported because it is a low-value performance refactor for a stable release branch and still had open review concerns around IGDB ID handling and incomplete optimization.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request refactors the SSRF protection mechanism by introducing a centralized resolveSafeAddress helper and a normalizeHostname utility in server/ssrf.ts. It updates several instances of standard fetch to use safeFetch across server/downloaders.ts, server/igdb.ts, and server/routes.ts, and adds corresponding tests for bracketed IPv6 literals and SSL fallback. The review feedback highlights several improvement opportunities: handling options.signal and calculating Content-Length in the custom fetchInsecure method, preserving the original error cause in resolveSafeAddress, and explicitly setting allowPrivate: false for external API requests (IGDB and Discord webhooks) as a defense-in-depth measure against SSRF.

Comment thread server/downloaders.ts
Comment on lines +3453 to 3470
private async fetchInsecure(url: string, options: RequestInit): Promise<Response> {
const parsedUrl = new URL(url);
const { address, family } = await resolveSafeAddress(parsedUrl.hostname, true);
const safeUrl = new URL(url);
safeUrl.hostname = family === 6 ? `[${address}]` : address;

const headers = new Headers(options.headers || {});
headers.set("Host", parsedUrl.hostname);

return new Promise((resolve, reject) => {
const req = https.request(
url,
safeUrl.toString(),
{
method: options.method || "GET",
headers: options.headers as unknown as import("http").OutgoingHttpHeaders,
headers: Object.fromEntries(headers.entries()) as import("http").OutgoingHttpHeaders,
rejectUnauthorized: false,
timeout: 30000,
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

In fetchInsecure, the custom https.request implementation has two issues:

  1. Ignoring options.signal: Timeout or abort signals set by the caller (e.g., AbortSignal.timeout) are ignored, which can lead to hung requests. We should pass signal: options.signal to https.request.
  2. Missing Content-Length: Unlike standard fetch, https.request does not automatically calculate the Content-Length header for Buffer or string bodies. This can cause some self-hosted downloaders to reject the request with 411 Length Required or hang. We should calculate and set Content-Length if options.body is present and not already set.
  private async fetchInsecure(url: string, options: RequestInit): Promise<Response> {
    const parsedUrl = new URL(url);
    const { address, family } = await resolveSafeAddress(parsedUrl.hostname, true);
    const safeUrl = new URL(url);
    safeUrl.hostname = family === 6 ? `[${address}]` : address;

    const headers = new Headers(options.headers || {});
    headers.set("Host", parsedUrl.hostname);

    if (options.body && !headers.has("content-length")) {
      if (typeof options.body === "string") {
        headers.set("content-length", String(Buffer.byteLength(options.body)));
      } else if (Buffer.isBuffer(options.body) || options.body instanceof Uint8Array) {
        headers.set("content-length", String(options.body.byteLength));
      }
    }

    return new Promise((resolve, reject) => {
      const req = https.request(
        safeUrl.toString(),
        {
          method: options.method || "GET",
          headers: Object.fromEntries(headers.entries()) as import("http").OutgoingHttpHeaders,
          rejectUnauthorized: false,
          timeout: 30000,
          signal: options.signal,
        },

Comment thread server/ssrf.ts
Comment on lines +43 to +49
} catch (error) {
if (error instanceof Error && error.message === "Invalid or unsafe URL") {
throw error;
}

throw new Error(`Failed to resolve hostname: ${normalizedHostname}`);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

In resolveSafeAddress, when dns.lookup fails, the catch block throws a generic error: throw new Error("Failed to resolve hostname: ...");. This discards the original error's details (such as ENOTFOUND or EAI_AGAIN), making troubleshooting and logging difficult.

We should preserve the original error by passing it as the cause option in the Error constructor.

Suggested change
} catch (error) {
if (error instanceof Error && error.message === "Invalid or unsafe URL") {
throw error;
}
throw new Error(`Failed to resolve hostname: ${normalizedHostname}`);
}
} catch (error) {
if (error instanceof Error && error.message === "Invalid or unsafe URL") {
throw error;
}
throw new Error(`Failed to resolve hostname: ${normalizedHostname}`, { cause: error });
}

Comment thread server/igdb.ts
Comment on lines +224 to 227
const response = await safeFetch(
`https://id.twitch.tv/oauth2/token?client_id=${clientId}&client_secret=${clientSecret}&grant_type=client_credentials`,
{
method: "POST",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

security-medium medium

Since IGDB is a public external API, we should explicitly set allowPrivate: false in the safeFetch options as a defense-in-depth measure. This prevents potential SSRF or DNS rebinding attacks targeting internal/private networks if the external domain's DNS is manipulated or hijacked.

    const response = await safeFetch(
      `https://id.twitch.tv/oauth2/token?client_id=${clientId}&client_secret=${clientSecret}&grant_type=client_credentials`,
      {
        method: "POST",
        allowPrivate: false,
References
  1. For defense-in-depth against Server-Side Request Forgery (SSRF), re-validate external URLs immediately before they are used, even if they were validated when saved.

Comment thread server/igdb.ts
Comment on lines +255 to 258
const response = await safeFetch(`https://api.igdb.com/v4/${endpoint}`, {
method: "POST",
headers: {
Accept: "application/json",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

security-medium medium

Similar to the authentication request, we should explicitly set allowPrivate: false in the safeFetch options for the IGDB API endpoint to prevent SSRF targeting internal/private networks.

Suggested change
const response = await safeFetch(`https://api.igdb.com/v4/${endpoint}`, {
method: "POST",
headers: {
Accept: "application/json",
const response = await safeFetch(`https://api.igdb.com/v4/${endpoint}`, {
method: "POST",
allowPrivate: false,
headers: {
Accept: "application/json",
References
  1. For defense-in-depth against Server-Side Request Forgery (SSRF), re-validate external URLs immediately before they are used, even if they were validated when saved.

Comment thread server/routes.ts
formData.append("file", new Blob([imageBuffer], { type: "image/png" }), "questarr-stats.png");

const discordRes = await fetch(webhookUrl, { method: "POST", body: formData });
const discordRes = await safeFetch(webhookUrl, { method: "POST", body: formData });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

security-medium medium

Since the Discord webhook is a public external service, we should explicitly set allowPrivate: false in the safeFetch options as a defense-in-depth measure. This prevents potential SSRF or DNS rebinding attacks targeting internal/private networks if the webhook URL's DNS is manipulated or hijacked.

Suggested change
const discordRes = await safeFetch(webhookUrl, { method: "POST", body: formData });
const discordRes = await safeFetch(webhookUrl, { method: "POST", body: formData, allowPrivate: false });
References
  1. For defense-in-depth against Server-Side Request Forgery (SSRF), re-validate external URLs immediately before they are used, even if they were validated when saved.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 27, 2026

Codecov Report

❌ Patch coverage is 97.36842% with 1 line in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
server/ssrf.ts 96.29% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 132a0f7cb1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread server/downloaders.ts
return new Promise((resolve, reject) => {
const req = https.request(
url,
safeUrl.toString(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the original TLS server name when pinning SAB requests

For HTTPS SABnzbd instances with self-signed certs behind a TLS virtual host or reverse proxy, this retry now connects to the resolved IP address, but the request options never set servername back to parsedUrl.hostname. Node does not use a hostname SNI value when the connection target is an IP, so those servers can present the default certificate or reject the handshake before the Host header is used; the previous retry connected to the original hostname and preserved SNI. Please pass the original hostname as the TLS servername when using safeUrl.toString().

Useful? React with 👍 / 👎.

@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
2 Security Hotspots

See analysis details on SonarQube Cloud

@Doezer Doezer added this to Questarr May 28, 2026
@github-project-automation github-project-automation Bot moved this to Backlog in Questarr May 28, 2026
@Doezer Doezer moved this from Backlog to In review in Questarr May 28, 2026
@Doezer Doezer added this to the 1.4.0 milestone May 28, 2026
@Doezer Doezer moved this from In review to In progress in Questarr May 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

1 participant