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.


Prevention & Best Practices

1. Always Encode at the Boundary

The rule is simple: encode data when it crosses a trust boundary into a new context. URL path context requires percent-encoding. Never rely on callers to pre-encode — encode at the point of use.

// ✅ Correct: encode each segment
const url = `https://example.com/${encodeURIComponent(repo)}/${encodeURIComponent(branch)}`;

// ❌ Wrong: raw interpolation
const url = `https://example.com/${repo}/${branch}`;

2. Use encodeURIComponent for Path Segments, Not encodeURI

encodeURI is designed for full URLs and deliberately leaves structural characters like /, ?, #, and @ unencoded. For path segments, use encodeURIComponent, which encodes all of those characters.

Function Encodes @ Encodes ? Encodes /
encodeURI
encodeURIComponent

3. Validate Before You Encode

Encoding prevents injection but doesn't validate intent. For repository identifiers, consider also validating format:

const REPO_PATTERN = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
if (!REPO_PATTERN.test(repo)) throw new Error("Invalid repository format");

This rejects obviously malformed input before encoding even runs.

4. Avoid Dynamic Regex Construction

When you need to match against a known set of keys, use a static lookup table or pre-compiled patterns. Dynamic new RegExp(userInput) is a regex injection risk (CWE-730).

5. Use the URL Constructor for Complex Cases

For more complex URL assembly, the URL constructor handles encoding correctly and throws on malformed input:

const base = new URL(`https://gitee.com`);
const path = `/${encodePathSegments(repo)}/raw/${encodePathSegments(branch)}/${encodePathSegments(file)}`;
const url = new URL(path, base);

OWASP & CWE References

  • CWE-116: Improper Encoding or Escaping of Output
  • CWE-20: Improper Input Validation
  • CWE-601: URL Redirection to Untrusted Site ('Open Redirect')
  • OWASP: Input Validation Cheat Sheet — always validate and encode at trust boundaries

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.


References

Frequently Asked Questions

What is URL injection in template literals?

URL injection occurs when user-controlled strings are interpolated into URLs without encoding, allowing special characters like `..`, `@`, or `?` to alter the URL's structure or destination.

How do you prevent URL injection in Node.js?

Use `encodeURIComponent()` on each individual path segment before embedding it in a URL. Never interpolate raw user input directly into template literal URLs.

What CWE is URL injection via improper encoding?

CWE-116 — Improper Encoding or Escaping of Output. Related CWEs include CWE-20 (Improper Input Validation) and CWE-601 (Open Redirect).

Is input validation alone enough to prevent URL injection?

Validation helps but is not sufficient on its own. Encoding is a complementary control — even inputs that pass validation may contain characters that need encoding to be safe in URL context.

Can static analysis detect URL injection in template literals?

Yes. Tools like Semgrep can trace tainted data from function parameters to URL sinks and flag unencoded interpolations. Orbis AppSec detected this exact pattern automatically.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #13

Related Articles

critical

How File Type Validation Bypass Happens in Node.js Image Processing and How to Fix It

A critical file type validation vulnerability in `src/js/insert.js` allowed attackers to rename malicious executables with image extensions and bypass security checks. The fix implements magic byte verification to confirm actual file content matches the declared file type, preventing attackers from disguising dangerous files as harmless images.

medium

How XML External Entity (XXE) Injection happens in Python and how to fix it

A high-severity XML External Entity (XXE) vulnerability was discovered in `utils/commands_extractors/find_java_repo_commands.py` where Python's native `xml.etree.ElementTree` library was used to parse potentially untrusted XML input. The fix replaces it with `defusedxml.ElementTree`, which disables external entity processing by default, preventing attackers from reading sensitive files or making unauthorized network requests.

high

How Cache-Control Header Injection Happens in Node.js HTTP Libraries and How to Fix It

CVE-2026-13697 is a high-severity vulnerability in the undici HTTP client library where the cache interceptor mishandles malformed Cache-Control directives, potentially leading to information disclosure and denial of service attacks. Upgrading from undici 7.28.0 to 7.29.0 (or 8.9.0 for v8 users) patches this vulnerability by implementing stricter validation of Cache-Control headers. This fix is critical for any Node.js application that relies on undici for HTTP requests, especially those handlin

critical

How XML Multiple Root Element Injection happens in Node.js and how to fix it

The foam3 project contained a critical vulnerability in xmldom version 0.6.0 that allowed attackers to create malformed XML documents with multiple root elements, violating the XML specification and potentially bypassing security validations. The fix removed the vulnerable xmldom dependency entirely from package.json and package-lock.json, eliminating the attack surface.

critical

How Prototype Pollution happens in Node.js and how to fix it

A critical prototype pollution vulnerability was discovered in `worker/import-core.js`, where `request.json()` parsed untrusted HTTP request bodies without filtering dangerous keys like `__proto__` and `constructor`. An attacker could send a crafted JSON payload to corrupt the global `Object` prototype, potentially affecting every object in the application runtime. The fix replaces the unsafe parse with a JSON reviver function that strips these dangerous keys before any object is constructed.

critical

How Information Disclosure via Malformed Cache-Control Directives Happens in Node.js and How to Fix It

A critical vulnerability (CVE-2026-13697) was discovered in the undici HTTP client library, allowing attackers to exploit malformed Cache-Control directives for information disclosure and denial of service. This fix upgrades undici from version 7.25.0 to 7.29.0 using npm overrides to ensure all nested dependencies receive the patched version.