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 toevil-host.comfile = "../../../sensitive-config.json"→ attempts path traversal on mirror servers that don't normalize pathsbranch = "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()inlib/client.jstrusted all three of its parameters completely —repo,branch, andfilewere interpolated raw into live HTTP fetch URLs, making URL injection trivially possible for any caller passing user-controlled input.- The
encodePathSegmentshelper is the right pattern for multi-segment paths — splitting on/, encoding each piece withencodeURIComponent, 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 + "...")insummarizeSkillFrontmatterwas a latent regex injection risk — replacing it with a staticKEY_PATTERNSobject 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, andfileparameters offetchRawText()— 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 theRAW_CANDIDATESarray. - Missing control: No
encodeURIComponentor 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/, appliesencodeURIComponentto every segment, and rejoins — applied to all three parameters at the top offetchRawText()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.