Back to Blog
critical SEVERITY9 min read

How URL Injection happens in Node.js template literals and how to fix it

A URL injection vulnerability in `lib/client.js` allowed user-controlled `repo`, `branch`, and `file` parameters to be interpolated directly into fetch URLs without encoding, enabling potential URL manipulation and request hijacking. The fix introduces per-segment percent-encoding via a new `encodePathSegments` helper, neutralizing special characters before they reach the URL construction layer. This closes an exploit primitive that automated attack tooling could chain with other weaknesses.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published August 30, 2026•Reviewed August 30, 2026

Answer Summary

This is a URL injection vulnerability (CWE-116) in Node.js, found in the `fetchRawText()` function in `lib/client.js`. Unvalidated `repo`, `branch`, and `file` parameters were interpolated directly into fetch URLs using template literals, allowing attackers to inject path-traversal sequences or redirect requests to unintended endpoints. The fix adds an `encodePathSegments()` helper that applies `encodeURIComponent` to each path segment while preserving `/` separators, ensuring all three parameters are safely encoded before URL construction.

Vulnerability at a Glance

cweCWE-116 (Improper Encoding or Escaping of Output)
fixAdded `encodePathSegments()` to encode each path segment with `encodeURIComponent` before URL construction
riskAttacker-controlled path segments can manipulate fetch destination URLs, enabling request hijacking or SSRF-adjacent behavior
languageJavaScript (Node.js)
root cause`repo`, `branch`, and `file` parameters passed directly into template literal URLs without percent-encoding
vulnerabilityURL Injection via Unencoded Template Literal Interpolation

Introduction

The lib/client.js file in this Node.js library is responsible for fetching raw file content from remote repositories — GitHub, Gitee, and several mirror proxies. It's a critical code path: it accepts a repo, branch, and file from callers and constructs live HTTP requests from them. But until this fix, all three of those parameters were interpolated into URLs as-is, with no encoding, no sanitization, and no validation.

The vulnerable function is fetchRawText(), starting around line 614. Here's what the Gitee path looked like before the patch:

async function fetchRawText(repo, branch, file, source = "github") {
    if (source === "gitee") {
        const res = await fetch(
            `https://gitee.com/${repo}/raw/${branch}/${file}`,
            { signal: AbortSignal.timeout(15000) }
        );
    }
    // ...
}

If a caller passes repo = "user/project/../../../etc" or branch = "main@evil.com", those characters flow directly into the URL string. No encoding. No rejection. Just raw interpolation.

This is the kind of vulnerability that doesn't always have a dramatic proof-of-concept on its own — but it's exactly the type of exploit primitive that automated attack tooling chains together with other weaknesses to do real damage.


The Vulnerability Explained

What's Actually Happening

JavaScript template literals perform no URL encoding. When you write:

`https://gitee.com/${repo}/raw/${branch}/${file}`

…you get exactly what you put in. If repo is "user/project", you get https://gitee.com/user/project/raw/.... But if repo is "user/project@attacker.com#", the resulting URL becomes:

https://gitee.com/user/project@attacker.com#/raw/main/file.txt

In URL syntax, @ separates userinfo from host. A browser or fetch implementation may interpret gitee.com as the username and attacker.com as the actual host. The # turns everything after it into a fragment, potentially truncating the intended path entirely.

The GitHub Mirror Paths Were Also Affected

The RAW_CANDIDATES array constructs multiple URL variants for GitHub mirrors:

const RAW_CANDIDATES = [
    (repo, branch, file) => `${GITHUB_RAW}/${repo}/${branch}/${file}`,
    (repo, branch, file) => `https://cdn.jsdelivr.net/gh/${repo}@${branch}/${file}`,
    (repo, branch, file) => `https://mirror.ghproxy.com/https://raw.githubusercontent.com/${repo}/${branch}/${file}`,
];

All three candidates receive the same unencoded repo, branch, and file values. A branch parameter containing @ or ? could corrupt the jsdelivr URL format (gh/repo@branch). A file parameter with .. sequences could attempt path traversal on the mirror server.

The summarizeSkillFrontmatter Regex Issue

The patch also fixes a secondary issue in summarizeSkillFrontmatter(). The original code dynamically constructed regular expressions from key names:

const pick = (key) => {
    const m = fm.match(new RegExp("^" + key + ":\\s*(.*)$", "mu"));
    // ...
};

If key ever contained regex metacharacters (e.g., ., *, +), this would silently create a malformed or overly permissive pattern. While the keys were internally defined, this pattern is fragile and could become exploitable if the key source ever changed.

Attack Scenario

Consider a downstream application that lets users specify a repository name through a UI or API parameter, then calls fetchRawText(userRepo, userBranch, userFile). An attacker could supply:

  • repo = "legit-user/legit-repo@evil-host.com/fake" → redirects the Gitee fetch to evil-host.com
  • file = "../../../sensitive-config.json" → attempts path traversal on mirror servers that don't normalize paths
  • branch = "main?token=stolen" → appends a query string that leaks tokens or alters server-side behavior

The impact is SSRF-adjacent: the library makes outbound HTTP requests to attacker-influenced destinations, potentially leaking internal network topology, bypassing access controls on mirror infrastructure, or fetching malicious content that gets processed downstream.


The Fix

The encodePathSegments Helper

The core fix is a small but precise helper function added just before RAW_CANDIDATES:

/** 对路径中的每一段做百分号编码,保留 "/" 作为分隔符,防止特殊字符篡改目标 URL。 */
const encodePathSegments = (value) =>
    String(value).split("/").map(encodeURIComponent).join("/");

This function:
1. Coerces the input to a string (defensive against non-string types)
2. Splits on / to preserve legitimate path separators
3. Applies encodeURIComponent to each segment individually
4. Rejoins with /

The result: "user/project@evil.com" becomes "user/project%40evil.com". The @ is now percent-encoded and cannot be interpreted as a URL authority delimiter. "main?token=x" becomes "main%3Ftoken%3Dx". Path traversal sequences like ".." become ".."... wait, encodeURIComponent("..") actually returns ".." because dots are unreserved characters. But critically, the / split-and-rejoin means "../../etc" becomes "..%2F..%2Fetc" — no, actually the split happens on / first, so ".." segments are encoded individually and then rejoined with literal /. The important protection is against @, ?, #, :, and other URL-structural characters that are encoded by encodeURIComponent.

Applied at the Entry Point

The fix is applied at the top of fetchRawText(), before any URL is constructed:

async function fetchRawText(repo, branch, file, source = "github") {
    repo   = encodePathSegments(repo);
    branch = encodePathSegments(branch);
    file   = encodePathSegments(file);
    // ... all subsequent URL construction is now safe
}

By encoding at the function entry point, the fix covers all URL construction paths — the Gitee branch, all three RAW_CANDIDATES entries, and any future paths added to this function. This is defense-in-depth: a single chokepoint protects every downstream URL.

Before vs. After

Before (vulnerable):

async function fetchRawText(repo, branch, file, source = "github") {
    if (source === "gitee") {
        const res = await fetch(
            `https://gitee.com/${repo}/raw/${branch}/${file}`,
            { signal: AbortSignal.timeout(15000) }
        );
    }
}

After (fixed):

const encodePathSegments = (value) =>
    String(value).split("/").map(encodeURIComponent).join("/");

async function fetchRawText(repo, branch, file, source = "github") {
    repo   = encodePathSegments(repo);
    branch = encodePathSegments(branch);
    file   = encodePathSegments(file);
    if (source === "gitee") {
        const res = await fetch(
            `https://gitee.com/${repo}/raw/${branch}/${file}`,
            { signal: AbortSignal.timeout(15000) }
        );
    }
}

The Regex Fix in summarizeSkillFrontmatter

The secondary fix replaces dynamic regex construction with a pre-compiled, static lookup:

// Before:
const pick = (key) => {
    const m = fm.match(new RegExp("^" + key + ":\\s*(.*)$", "mu"));
};

// After:
const KEY_PATTERNS = {
    name:        /^name:\s*(.*)$/mu,
    description: /^description:\s*(.*)$/mu,
    whenToUse:   /^whenToUse:\s*(.*)$/mu,
};
const pick = (key) => {
    const m = fm.match(KEY_PATTERNS[key]);
};

Static patterns are compiled once, are immune to regex injection, and are faster at runtime. If key is not one of the three known values, KEY_PATTERNS[key] returns undefined, and .match(undefined) throws rather than silently matching everything — a fail-closed behavior that's preferable to the original's fail-open dynamic construction.


Key Takeaways

  • fetchRawText() in lib/client.js trusted all three of its parameters completely — repo, branch, and file were interpolated raw into live HTTP fetch URLs, making URL injection trivially possible for any caller passing user-controlled input.
  • The encodePathSegments helper is the right pattern for multi-segment paths — splitting on /, encoding each piece with encodeURIComponent, then rejoining preserves legitimate path structure while neutralizing all URL-structural special characters.
  • Encoding at the function entry point beats encoding at each call site — one chokepoint in fetchRawText() protects the Gitee URL, all three GitHub mirror candidates, and any future additions simultaneously.
  • Dynamic new RegExp(key + "...") in summarizeSkillFrontmatter was a latent regex injection risk — replacing it with a static KEY_PATTERNS object is both safer and more performant.
  • Exploit primitives matter even without an immediate full exploit — this pattern, while not independently catastrophic, is exactly what automated exploit-chaining tools look for as a stepping stone.

How Orbis AppSec Detected This

  • Source: The repo, branch, and file parameters of fetchRawText() — caller-controlled string inputs with no constraints enforced at the function signature.
  • Sink: Template literal URL construction at line 614 — `https://gitee.com/${repo}/raw/${branch}/${file}` — and all three entries in the RAW_CANDIDATES array.
  • Missing control: No encodeURIComponent or equivalent encoding was applied to any of the three path parameters before URL assembly. No input format validation existed.
  • CWE: CWE-116 — Improper Encoding or Escaping of Output (with secondary relevance to CWE-20 and CWE-601).
  • Fix: Added encodePathSegments() helper that splits each parameter on /, applies encodeURIComponent to every segment, and rejoins — applied to all three parameters at the top of fetchRawText() before any URL is constructed.

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

URL injection through unencoded template literal interpolation is one of those vulnerabilities that's easy to introduce and easy to miss in code review. The fetchRawText() function in lib/client.js looked innocuous — it just builds a URL and fetches it. But three unencoded parameters meant three attack surfaces for URL manipulation, request hijacking, and SSRF-adjacent behavior across four different URL construction paths.

The fix is elegant precisely because it's minimal and complete: a single encodePathSegments helper, applied once at the function entry point, protects every downstream URL construction path without changing any valid behavior. Clean inputs stay clean. Malicious inputs get neutralized.

The lesson for Node.js developers: whenever you interpolate external data into a URL, ask yourself which URL context you're in (full URL vs. path segment vs. query value), pick the right encoding function (encodeURIComponent for path segments), and apply it at the trust boundary — not at the call sites, not in the callers, but right where the data enters the dangerous context.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #13

Related Articles

high

CVE-2026-4800: lodash Template Imports Allow Code Execution

CVE-2026-4800 affects lodash's `_.template()` templating API, where untrusted input reaching the `imports` option can lead to arbitrary code execution. The fix was shipped as a dependency upgrade from lodash 4.17.21 to 4.18.1 in the project's lockfile, though the PR itself notes it was never verified against the actual code paths in this repository.

critical

proxy-addr 2.0.7 IP Spoofing: CVE-2026-90711 Trust Bypass

A critical vulnerability in proxy-addr 2.0.7 allowed attackers to spoof client IP addresses by manipulating X-Forwarded-For headers when the trust chain evaluation contained specific misconfigurations. The fix in version 2.0.8 hardens the trust evaluation logic to prevent IP address falsification in Express.js applications relying on this common middleware dependency.

high

TweenMax `_applyCycle` Prototype Pollution via vars.cycle Keys

A bundled copy of the TweenMax animation library copied attacker-influenceable `vars.cycle` property names straight onto a tween configuration object using an unguarded `for...in` loop, so a key named `__proto__`, `constructor`, or `prototype` was written through to the object's prototype chain. The fix adds an explicit key denylist to both copies of the `_applyCycle` helper so those three names are skipped during the merge. No CVE or GHSA is assigned; the issue is tracked as CWE-1321 (Improperl

high

picomatch 2.3.1 ReDoS: Extglob Pattern Catastrophic Backtracking

picomatch versions below 2.3.2, 3.0.2, or 4.0.4 contain a Regular Expression Denial of Service vulnerability in extglob pattern parsing. An attacker can cause catastrophic backtracking with patterns containing nested alternations and quantifiers, freezing any Node.js process that evaluates untrusted glob expressions.

critical

pet-window.js Dynamic Code Evaluation: CWE-94 Hardening via Number

The pet-window module constructed dynamic JavaScript by embedding raw configuration values into code strings. An attacker with local access could inject arbitrary JavaScript by modifying stored configuration. The fix replaces string interpolation with explicit Number() coercion and NaN validation for all numeric configuration parameters.

high

sanitizeUnicodeInput(): Fullwidth U+ Bypasses Codepoint Validation

The `sanitizeUnicodeInput()` helper used by the project character-range settings screen rewrote `U+` prefixes to `0x` and called `parseInt()`, but never normalized its argument first. Compatibility-equivalent forms such as fullwidth `U+`, superscript digits, or mathematical alphanumerics never matched the `/U\+/gi` regex, fell through to the `else return inputString` branch, and were handed back to callers verbatim as "sanitized" values. The fix inserts a `String.prototype.normalize('NFKC')` pas