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
Published August 30, 2026Reviewed 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 completelyrepo, 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

critical

LDAP Filter Injection in da_unique_email_validator Fixed

The registration-time email uniqueness validator, `da_unique_email_validator`, formatted the submitted email address straight into an LDAP search filter with Python's `%` operator, so filter metacharacters in the email were interpreted as filter syntax. The fix wraps the value in `ldap.filter.escape_filter_chars()` (and imports the `ldap.filter` submodule explicitly), so a submitted address is always treated as a literal attribute value. Any deployment with `ldap login` enabled and a bind accoun

high

installPlugin(): Unvalidated npm Package Names Reach npm install

A plugin manager service exposed an `installPlugin(plugin: PluginInfo)` method that passed `plugin.packageName` and `plugin.version` straight into the platform's npm install routine with no validation, no blocklist, and no integrity verification of the fetched tarball. Because npm treats a non-semver "version" as a fetch specifier — a tarball URL, a git ref, a local path — an attacker who could influence the plugin listing could get arbitrary code installed and executed with full Electron/Node p

critical

deleteNestedProperty Prototype Pollution via Dot-Notation Path

The `deleteNestedProperty` function in propertyUtils.ts allowed attackers to manipulate JavaScript object prototypes by passing specially crafted dot-notation paths like `__proto__.polluted`. A fix now blocks dangerous keys before processing, preventing prototype pollution attacks that could affect all objects in the application.

critical

eval() in Async Function Constructor Enables Runtime Escape

The eval.mjs command handler used raw `eval()` to execute JavaScript expressions, creating a critical code injection path if owner credentials are compromised. The fix replaces `eval()` with the `AsyncFunction` constructor and explicitly shadows `process`, `require`, and other runtime globals as parameters, preventing evaluated code from reaching the Node.js runtime even when authentication boundaries fail.

high

How Regular Expression Denial of Service (ReDoS) Happens in Node.js trim-newlines and How to Fix It

CVE-2021-33623 exposed a Regular Expression Denial of Service (ReDoS) vulnerability in the npm package `trim-newlines` versions 1.0.0 and earlier. The vulnerable `.end()` method used an inefficient regex pattern that could cause severe performance degradation when processing malicious input. Upgrading to version 4.0.1 patches the regex implementation and eliminates the attack surface.

critical

How CSS Injection via Weak Pattern Validation happens in Vue.js and how to fix it

A critical CSS injection vulnerability in `testpage/App.vue` allowed attackers to bypass weak HTML5 pattern validation and load malicious stylesheets. The fix replaces direct variable assignment with a hardened `setCustomStylesheetHref()` method using strict regex validation.