Back to Blog
high SEVERITY3 min read

boardcards.js parseArgs() SSRF: --board Flag Reaches Metadata IPs

The `parseArgs()` function in boardcards.js accepted a user-controlled `--board` command-line argument and passed it directly to fetch() calls without validating the URL scheme or hostname. An attacker could supply `http://169.254.169.254/latest/meta-data/` or other internal addresses to extract cloud credentials or scan internal networks.

O
By Orbis AppSec
•Published September 28, 2026•Reviewed September 28, 2026

Answer Summary

The `parseArgs()` function in boardcards.js accepted arbitrary values via the `--board` command-line flag without validation. An attacker could supply cloud metadata endpoints like `169.254.169.254` or internal network addresses to perform SSRF attacks, extracting IAM credentials or probing internal services. The fix adds an `isAllowedBoard()` validation function that rejects non-HTTP(S) protocols and blocks known metadata hostnames, with improved error messaging. CWE-918 (Server-Side Request Forgery).

Vulnerability at a Glance

cweCWE-918
fixAdded isAllowedBoard() whitelist enforcing HTTP(S) scheme and blocking metadata endpoints
riskCloud credential theft, internal network reconnaissance
languageJavaScript (Node.js)
root causeUser-controlled --board parameter passed to fetch() without URL validation
vulnerabilityServer-Side Request Forgery (SSRF)

Affected Versions

Affected not applicable (first-party code)
Fixed in commit adding isAllowedBoard() validation
Ecosystem Node.js
CVE / GHSA not assigned
CWE CWE-918 (Server-Side Request Forgery)

The Vulnerability Explained

The boardcards.js utility accepts a --board command-line parameter to specify which board service endpoint to query. The parseArgs() function processed this input with a critical gap: it accepted any string value and passed it through to subsequent fetch() operations without validating whether the URL was safe to request.

Here's the vulnerable pattern:

function parseArgs(argv) {
  const opts = {
    board: process.env.BOARD_ORIGIN || 'http://127.0.0.1:3100',
    // ...
  };
  // ...
  opts.board = String(opts.board || '').replace(/\/+$/, '');
  if (!opts.board) {
    throw new Error('--board wants an origin');
  }
  return opts;
}

The problem sits at the validation boundary. The code only checked for presence (!opts.board), not for safety. An attacker controlling the --board argument could supply:

  • http://169.254.169.254/latest/meta-data/iam/security-credentials/ — AWS EC2 instance metadata
  • http://metadata.google.internal/computeMetadata/v1/ — Google Cloud metadata
  • http://10.0.0.1:22/ — Internal SSH services for port scanning
  • file:///etc/passwd — Local file access (depending on fetch implementation)

Since the board URL is used in downstream fetch() calls, this becomes a textbook Server-Side Request Forgery vulnerability. In cloud deployments, this routinely leads to IAM credential exposure and lateral movement.

The Fix

The patch introduces isAllowedBoard(), a purpose-built validation function that enforces two security boundaries:

function isAllowedBoard(urlStr) {
  let u;
  try { u = new URL(urlStr); } catch { return false; }
  if (u.protocol !== 'http:' && u.protocol !== 'https:') { return false; }
  if (u.hostname === '169.254.169.254' || u.hostname === 'metadata.google.internal') { return false; }
  return true;
}

The validation now rejects:
- Non-HTTP(S) protocols (blocking file://, ftp://, data://, etc.)
- Known cloud metadata endpoints by hostname

The call site was updated to use this validator:

  if (!opts.board || !isAllowedBoard(opts.board)) {
    throw new Error('--board wants a plain http(s) origin, not a link-local/metadata address');
  }

This change transforms an open SSRF into a restricted, allowlist-based validation. The error message improvement also aids debugging—users now understand why their URL was rejected.

Key Takeaways

  • Command-line arguments are untrusted input: Even CLI tools face SSRF risks when parameters become URLs. Treat process.argv with the same skepticism as HTTP query parameters.

  • Environment variables need validation too: The BOARD_ORIGIN fallback means environment-sourced values must pass the same isAllowedBoard() check as explicit --board arguments.

  • Protocol validation prevents protocol smuggling: Rejecting non-HTTP(S) schemes closes avenues for file:// reads, data:// injection, and other fetch-based attacks.

  • Metadata endpoints are high-value targets: Explicitly blocking 169.254.169.254 and metadata.google.internal addresses the most common cloud SSRF exploitation pattern.

  • Error messages are security UX: The updated error message educates users about valid input patterns while revealing nothing exploitable to attackers.

How Orbis AppSec Detected This

Source: The --board command-line argument accepted by parseArgs()

Sink: fetch() calls using the unsanitized board URL

Missing control: No validation of URL scheme, hostname, or IP address before network request initiation

CWE: CWE-918 — Server-Side Request Forgery (SSRF)

Fix: Added isAllowedBoard() function enforcing HTTP(S) protocol requirement and explicit denylist for cloud metadata hostnames, with updated error messaging in parseArgs().

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

This vulnerability demonstrates that SSRF isn't limited to web applications with HTTP frontends. Any code that accepts URLs from user input—whether via CLI flags, environment variables, or configuration files—must validate those URLs before making network requests. The isAllowedBoard() pattern provides a template: parse strictly, whitelist protocols, and explicitly deny known-sensitive hostnames.

Prevention and further reading

Frequently Asked Questions

Does the `isAllowedBoard()` function block all link-local addresses, or only the specific cloud metadata endpoints 169.254.169.254 and metadata.google.internal?

The current implementation specifically blocks `169.254.169.254` and `metadata.google.internal` by hostname check, combined with protocol validation. The defense-in-depth relies on both the explicit hostname denylist and the requirement for a plain HTTP(S) origin.

Why was the error message changed from "--board wants an origin" to "--board wants a plain http(s) origin, not a link-local/metadata address"?

The original message was ambiguous about what constituted a valid origin. The new message explicitly tells users that link-local and metadata addresses are rejected categories, reducing confusion when legitimate-looking URLs fail validation.

If `process.env.BOARD_ORIGIN` is set but not passed via `--board`, does `isAllowedBoard()` still validate it before use?

Yes. The validation occurs in `parseArgs()` after the default value from `process.env.BOARD_ORIGIN` is assigned, so all code paths—including environment variable configuration—are subject to the same `isAllowedBoard()` check.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #21

Related Articles

high

fetch() with Automatic Redirects Leaks HTTPS Trust to api.tomys.top

The `withProxy()` fetch wrapper allowed automatic HTTP redirects from `api.tomys.top`, trusting the external API's domain integrity without validating final destinations. A compromised or malicious redirect response could send users to attacker-controlled phishing sites while maintaining HTTPS protocol and hostname checks that falsely appear secure.

high

Meta.get() Honored @uploadURL From Script Headers: SSRF Fix

A userscript manager's metadata parser accepted the `@uploadURL` directive from a script's own header block, so any installed or auto-updated script could silently redirect the options-page `uploadScript()` fetch to an attacker-chosen destination — including loopback, link-local metadata endpoints, and LAN addresses. The fix makes `Meta.get()` ignore `@uploadURL` when it appears in a script header, leaving the User Metadata field (validated in `Meta.getUserMeta()`) as the only way to set it, and

high

Unvalidated thumbnailUrl Passed to axios.get(): SSRF Risk

A server-side image helper passes a `thumbnailUrl` value — sourced from 3D-print file metadata returned by an OctoPrint instance — straight into `axios.get()` with no scheme, host, or IP validation. Anyone who can write that metadata (a crafted G-code file, or a compromised/spoofed OctoPrint endpoint) turns the application into an HTTP proxy for internal networks and cloud metadata services. The accompanying pull request bumps `react-router-dom` from 7.9.1 to 7.18.4 to pick up the CVE-2026-21884

high

esearch() SSRF: requests.get() Trusted Any Host in the URL

A citation format-conversion script used by an AI research skill built HTTP URLs from user-supplied PMIDs, DOIs, arXiv IDs, and free-text queries, then passed the resulting string straight to `requests.get()` with no check that it still pointed at an intended API host. The fix introduces an `ALLOWED_HOSTS` set containing the three real upstream APIs and an `_is_allowed_url()` helper that compares `urlparse(url).hostname` against it before the request is issued. This closes a CWE-918 server-side

high

updateCardBg() Follows Unvalidated 302 Location Headers

A background-image updater fetched a configured image URL with manual redirect handling and then re-issued the request to whatever `Location` header came back, with no scheme or host checks. A redirect to `http://169.254.169.254/` or `http://127.0.0.1:<port>/` would have been followed with the original fetch options attached, and the response body written to disk as an image asset. The fix resolves the redirect target against `imgDownloadUrl` and rejects anything that is not HTTPS on the same ho

high

bookDir() Path Traversal via Unsanitized bookId Parameter

The `bookDir()` function accepted unsanitized `bookId` values derived from user-created book titles, enabling path traversal attacks through `../` sequences. A fix was applied that validates the identifier using `path.basename()` and throws on mismatch, ensuring all resolved paths remain within `LIBRARY_DIR`.