Skip to main content
interlace
Plugin: express-securityRules

no-user-controlled-render-locals

Disallow res.render() with locals or view names sourced wholesale from req.body / req.query / req.params

Keywords: template injection, render locals, CWE-73, external control of file name or path, res.render, view engine, pug, ejs, path traversal, ESLint rule, LLM-optimized

Detects template-object injection where res.render() receives locals wholly controlled by the request, or a view name derived from request input. This rule is part of eslint-plugin-express-security and provides LLM-optimized error messages.

⚠️ This rule errors by default in the recommended config.

Quick Summary

AspectDetails
CWE ReferenceCWE-73 (External Control of File Name or Path)
Severity🔴 High
Auto-Fix💡 Suggestion (removes the request-object spread)
CategorySecurity
Best ForExpress.js apps with server-side templates (Pug, EJS, Handlebars)

Value & investment case

Why this rule pays for itself. Framework: cicd-impact/philosophy.md.

DimensionValue
CWECWE-73 — External Control of File Name or Path
Feedback-loop tierEditor / pre-commit (sub-second) — cheapest layer per the feedback-loop hierarchy
Defensive-layer leverage~10× cheaper than unit-test · ~1,000× cheaper than production rollback · 10,000+× cheaper than customer disclosure (cost-ratio anchors)
Niche relevanceCritical: B2C apps with server-rendered pages, CMS/marketplace platforms · High: B2B SaaS with templated emails/PDF exports · Medium: API-only services
Investor-frame impactres.render(view, req.body) hands the view engine its own configuration: a request key like layout, settings or cache can point the engine at attacker-chosen files, escalating to local file disclosure or (engine-dependent) RCE. Catch at lint-time removes the entire class.

Read also: philosophy.md §investor-frame · niche-presets.json · analyzer-evaluation-framework.md

Vulnerability and Risk

Vulnerability: Express merges the render locals object into the view-engine options. When the whole req.body / req.query / req.params object is forwarded — directly, via spread, or via a variable — every request key becomes an engine option.

Risk: An attacker posts {"layout": "../../etc/passwd"} (or settings, cache, filename, engine-specific keys) and reconfigures the template engine: local file disclosure, template-cache poisoning, and in some engines code execution. A user-controlled VIEW name additionally enables traversal into unintended templates.

How the Attack Works

Rule Logic Flow

Detection Patterns

PatternRiskDescription
res.render(view, req.body)🔴 CriticalWhole request object as locals
res.render(view, { ...req.query })🔴 CriticalSpread forwards every request key
const l = req.body; res.render(view, l)🔴 HighWhole object via single assignment
res.render(view, transform(req.body))🟡 HighUnknown call forwarding the whole object
res.render(req.params.page)🔴 HighUser-controlled view name (path traversal)
res.render('pages/' + req.query.p)🔴 HighView path built from request input

Examples

❌ Incorrect

// Whole request object as locals - VULNERABLE
app.post('/preview', (req, res) => {
  res.render('preview', req.body);
});

// Spread forwards every request key - VULNERABLE
app.get('/newsletter', (req, res) => {
  res.render('newsletter', { ...req.query, generatedAt: Date.now() });
});

// Single assignment does not sanitize - VULNERABLE
const locals = req.body;
res.render('post', locals);

// User-controlled view name - VULNERABLE (path traversal)
res.render(req.query.view);
res.render(`pages/${req.params.name}`);

✅ Correct

// Field-picking is THE safe pattern - SAFE
app.post('/preview', (req, res) => {
  res.render('preview', {
    title: String(req.body.title || ''),
    body: String(req.body.body || ''),
    authorName: String(req.body.authorName || 'anonymous'),
  });
});

// Static locals - SAFE
res.render('home', { title: 'Welcome' });

// Allowlisted sanitizer (see options) - SAFE
res.render('post', pick(req.body, ['title', 'body']));

// Fixed view names mapped from input - SAFE
const view = ALLOWED_VIEWS.has(req.query.tab) ? req.query.tab : 'default';

Note: the last example assigns a single field (req.query.tab) — the rule flags view names derived from request input like res.render(req.query.tab); map input onto a fixed set of literals instead.

Error Message Format

The rule provides LLM-optimized error messages (Compact 2-line format) with actionable security guidance:

🔒 CWE-73 | Template Object Injection (CWE-73) | HIGH
   Fix: Pick only the fields the template needs into an explicit object literal | https://cwe.mitre.org/data/definitions/73.html

Message Components

ComponentPurposeExample
Risk StandardsSecurity benchmarksCWE-73
Issue DescriptionSpecific vulnerabilityTemplate Object Injection / User-Controlled View Path
Severity & ComplianceImpact assessmentHIGH
Fix InstructionActionable remediationPick only the fields the template needs
Technical TruthOfficial referenceCWE-73

Configuration

{
  rules: {
    "express-security/no-user-controlled-render-locals": ["error", {
      allowSanitizers: ["pick", "sanitizeLocals"]
    }]
  }
}

Options

OptionTypeDefaultDescription
allowSanitizersstring[][]Callee names that make a wrapped locals expression safe (plain pick(...) and member _.pick(...) calls both match)

Best Practices

1. Pick Fields, Never Forward Objects

res.render('profile', {
  name: String(req.body.name || ''),
  bio: String(req.body.bio || ''),
});

2. Centralize a Sanitizer and Allowlist It

// locals.js
export const pickLocals = (source, fields) =>
  Object.fromEntries(
    fields.filter((f) => f in source).map((f) => [f, String(source[f])]),
  );

// route — with allowSanitizers: ['pickLocals']
res.render('post', pickLocals(req.body, ['title', 'body']));

3. Map User Input to Fixed View Names

const VIEWS = { news: 'pages/news', jobs: 'pages/jobs' };
res.render(VIEWS[req.query.tab] ?? 'pages/home');

Known False Negatives

The following patterns are not detected due to static analysis limitations:

Reassignment After Declaration

Why: Only single-assignment const/let declarations are tracked.

// ❌ NOT DETECTED - Reassigned later
let locals = {};
locals = req.body;
res.render('post', locals);

Mitigation: Prefer const with explicit field-picking.

Cross-Function Flow

Why: The rule does not follow values through function boundaries.

// ❌ NOT DETECTED - Locals built elsewhere
function buildLocals(req) {
  return req.body;
}
res.render('post', buildLocals(req));

Mitigation: Sanitize at the render call site; allowlist trusted helpers via allowSanitizers.

Partial Object Forwarding

Why: A nested request object (req.body.profile) is a field access, and field-picking is treated as the safe pattern.

// ❌ NOT DETECTED - Nested object still user-shaped
res.render('profile', req.body.profile);

Mitigation: Pick scalar fields, not sub-objects.

Computed Source Properties

Why: req['body'] uses a computed member access that is not matched.

// ❌ NOT DETECTED - Computed access
res.render('post', req['body']);

Mitigation: Avoid computed access to request sources.

Resources

Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.