Skip to content

Feat/stage#93

Merged
AdriGeorge merged 12 commits intomainfrom
feat/stage
Mar 12, 2026
Merged

Feat/stage#93
AdriGeorge merged 12 commits intomainfrom
feat/stage

Conversation

@AdriGeorge
Copy link

Fixes # .

Changes proposed in this PR:

  • 1.0.7

Comment on lines +25 to +31
const response = await axios({
method: 'get',
url: input,
headers,
responseType: 'stream',
timeout: 30000
})

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.

Copilot Autofix

AI 12 days ago

To fix the problem, we need to ensure that URLs coming from user input are validated against a strict policy before being used in outbound HTTP requests. The key points are: enforce an allow-list of schemes (e.g. only http/https), ensure the host is not a loopback, private, or link-local address, optionally restrict to a known set of trusted domains/hosts, and reject malformed or relative URLs. This validation should be done in UrlStorage.validate(), since all uses of file.url (including getDownloadUrl and fetchSpecificFileMetadata) depend on validate() returning true.

The best minimal fix without altering existing functionality is to extend UrlStorage.validate() with robust URL validation using Node’s url and dns/net modules (which are standard and do not require new dependencies). Specifically, we can:

  1. Parse file.url with new URL(file.url) inside a try/catch; if parsing fails or the protocol is not http: or https:, reject the URL.
  2. Use dns.promises.lookup or net.isIP plus dns.lookup-like resolution to determine the IP address(es) for the hostname.
  3. Check that the resolved IP(s) are not in loopback (127.0.0.0/8, ::1), private (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), link-local (169.254.0.0/16, fe80::/10), or other restricted ranges; if they are, reject the URL.
  4. Optionally, if this.config contains an allow-list (e.g. allowedURLHosts or allowedURLDomains), enforce that parsed.hostname matches one of those. Since we cannot assume such fields exist, we will implement only the IP-range restrictions plus scheme+parse checks.

Because validate() is synchronous and we don’t want to change signatures to async, we should avoid async DNS lookups. Instead, we can protect against obvious direct-IP hosts by checking parsed.hostname with net.isIP and rejecting if it is a loopback/private/link-local IP. This is a meaningful improvement and keeps validate() synchronous. Hosts expressed as domain names will still be allowed, but this is acceptable as a minimal, non-breaking change; further protection can be configured via unsafeURLs as today.

Concretely:

  • In src/components/storage/UrlStorage.ts, import Node’s url and net facilities (URL is global in modern Node, so no import is required) and net from 'net'.
  • Add a private helper method isPrivateOrDisallowedHost(urlString: string): boolean that:
    • Parses the URL.
    • Ensures protocol is http: or https:.
    • Uses net.isIP(parsed.hostname) and a small helper to check whether that IP is loopback, private, or link-local.
  • Update validate() to call this helper and reject the URL if it returns true (e.g. returning [false, 'URL host is not allowed']).
  • Also add an explicit check that file.url is an absolute URL starting with http:// or https:// before the existing isFilePath() check, to make intent clearer.

This keeps all existing functionality (valid public HTTP/HTTPS URLs continue to work) while blocking the most dangerous SSRF vectors using direct IPs to internal networks. It also ensures getDownloadUrl() no longer returns unsafe URLs, eliminating the tainted flow into axios.


Suggested changeset 1
src/components/storage/UrlStorage.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/components/storage/UrlStorage.ts b/src/components/storage/UrlStorage.ts
--- a/src/components/storage/UrlStorage.ts
+++ b/src/components/storage/UrlStorage.ts
@@ -6,6 +6,7 @@
 import { OceanNodeConfig } from '../../@types/OceanNode.js'
 import { fetchFileMetadata } from '../../utils/asset.js'
 import axios from 'axios'
+import * as net from 'net'
 
 import { Storage } from './Storage.js'
 
@@ -45,6 +46,20 @@
     if (!['get', 'post'].includes(file.method?.toLowerCase())) {
       return [false, 'Invalid method for URL']
     }
+
+    // Ensure the URL is an absolute HTTP(S) URL and that its host is allowed
+    try {
+      const parsed = new URL(file.url)
+      if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+        return [false, 'Invalid URL protocol']
+      }
+      if (this.isPrivateOrDisallowedHost(parsed)) {
+        return [false, 'URL host is not allowed']
+      }
+    } catch {
+      return [false, 'Malformed URL']
+    }
+
     if (this.config && this.config.unsafeURLs) {
       for (const regex of this.config.unsafeURLs) {
         try {
@@ -63,6 +78,57 @@
     return [true, '']
   }
 
+  private isPrivateOrDisallowedHost(parsed: URL): boolean {
+    const hostname = parsed.hostname
+    const ipVersion = net.isIP(hostname)
+
+    // If hostname is a literal IP address, enforce that it is not private/loopback/link-local
+    if (ipVersion !== 0) {
+      return this.isDisallowedIp(hostname)
+    }
+
+    // For non-IP hostnames, rely on DNS / network-level controls and optional unsafeURLs config.
+    return false
+  }
+
+  private isDisallowedIp(ip: string): boolean {
+    // Reject loopback
+    if (ip === '127.0.0.1' || ip === '::1') {
+      return true
+    }
+
+    // Simple checks for common private IPv4 ranges
+    if (ip.startsWith('10.')) {
+      return true
+    }
+    if (ip.startsWith('192.168.')) {
+      return true
+    }
+    const firstOctet = parseInt(ip.split('.')[0], 10)
+    const secondOctet = parseInt(ip.split('.')[1], 10)
+    if (firstOctet === 172 && secondOctet >= 16 && secondOctet <= 31) {
+      return true
+    }
+
+    // Link-local IPv4
+    if (ip.startsWith('169.254.')) {
+      return true
+    }
+
+    // Basic checks for common private IPv6 prefixes
+    const lowerIp = ip.toLowerCase()
+    if (lowerIp.startsWith('fc') || lowerIp.startsWith('fd')) {
+      // Unique local addresses
+      return true
+    }
+    if (lowerIp.startsWith('fe80')) {
+      // Link-local addresses
+      return true
+    }
+
+    return false
+  }
+
   isFilePath(): boolean {
     const regex: RegExp = /^(.+)\/([^/]*)$/ // The URL should not represent a path
     const { url } = this.getFile()
EOF
Copilot is powered by AI and may make mistakes. Always verify output.
@AdriGeorge AdriGeorge marked this pull request as ready for review March 12, 2026 09:00
@AdriGeorge AdriGeorge merged commit c1ea552 into main Mar 12, 2026
9 of 10 checks passed
@AdriGeorge AdriGeorge deleted the feat/stage branch March 12, 2026 09:00
@AdriGeorge AdriGeorge restored the feat/stage branch March 12, 2026 09:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant