Command, Path and Template Injection

Three ways user input escapes into something that executes it — a shell, a filesystem path, or a template engine.

Injection is not only about databases. Any interpreter that receives attacker-influenced text can be steered, and APIs commonly hand text to three of them.

Command injection

Passing user input into a shell — image conversion, PDF generation, a git call, a ping diagnostic. The shell interprets ;, |, &&, backticks and $(), so a filename becomes a command.

The fix: do not use a shell. execFile or spawn with an argument array passes arguments directly to the process, with no shell to interpret anything. Never exec with an interpolated string, and never trust that quoting is sufficient.

Path traversal

User input in a file path, and ../../../etc/passwd escapes the directory you intended. Variants use URL encoding (%2e%2e%2f), double encoding, backslashes on Windows, and null bytes.

The fix: never build a path from user input. Store files under generated names, look up the real path from your database by id, and if you must accept a name, resolve the final path and verify it is still inside the base directory.

Server-side template injection

User input evaluated as a template, not merely rendered into one. It appears in email templates, report builders, and "customisable" messages. Depending on the engine, it escalates from information disclosure to full remote code execution, because templates can reach the runtime.

The fix: templates come from your codebase; user input is only ever a value passed to them. If users genuinely need custom templates, use a sandboxed logic-less engine and a strict allowlist of variables.

Example

Example · javascript
import { execFile } from 'node:child_process';

// ❌ A shell parses this. "file.png; rm -rf /" is two commands.
exec(`convert ${req.body.filename} output.jpg`);

// ❌ Quoting is not a fix — it is a guess about the shell's grammar.
exec(`convert "${req.body.filename}" output.jpg`);
// filename: file.png" ; rm -rf / ; echo "

// ✅ No shell at all. Arguments go straight to the process.
execFile('convert', [safePath, '-resize', '800x600', outputPath], {
  timeout: 30_000,
  maxBuffer: 10 * 1024 * 1024,
});

When to use it

  • A thumbnail service is compromised through a filename passed into a shell command for image conversion.
  • A document download endpoint is used to read /etc/passwd because the filename came from the query string.
  • A customisable email template lets a user execute code on the server because the engine evaluated their input as a template rather than as data.

More examples

Running an external tool safely

Passing an empty env is a small detail with real value: a compromised converter cannot read your database URL out of the process environment.

Example · javascript
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import path from 'node:path';
import crypto from 'node:crypto';

const run = promisify(execFile);
const WORK_DIR = '/var/app/work';

export async function generateThumbnail(uploadId, userId) {
  // 1. The path comes from OUR database, never from the request.
  const upload = await db('uploads').where({ id: uploadId, user_id: userId }).first();
  if (!upload) throw new NotFoundError();

  // 2. Storage keys are generated, not derived from the user's filename.
  const input = path.join(WORK_DIR, upload.storage_key);
  const output = path.join(WORK_DIR, `${crypto.randomUUID()}.jpg`);

  // 3. Confirm both paths are still inside the base directory.
  for (const p of [input, output]) {
    if (!path.resolve(p).startsWith(path.resolve(WORK_DIR) + path.sep)) {
      throw new Error('path escapes the working directory');
    }
  }

  // 4. execFile: an ARGUMENT ARRAY, no shell, so nothing is interpreted.
  try {
    await run('convert', [
      `${input}[0]`,               // first frame only — bounds animated inputs
      '-resize', '400x400>',
      '-strip',                    // drop EXIF, which can carry payloads
      '-quality', '85',
      output,
    ], {
      timeout: 30_000,             // 5. never let it hang
      maxBuffer: 1024 * 1024,      // 6. bound the output
      killSignal: 'SIGKILL',
      env: {},                     // 7. an empty environment: no secrets leak in
      cwd: WORK_DIR,
    });
  } catch (err) {
    // 8. Never return the tool's stderr — it contains paths and versions.
    logger.error({ err, uploadId }, 'thumbnail generation failed');
    throw new UnprocessableError('could not process this image');
  }

  return output;
}

// ⚠️ Even done correctly, invoking a media library on untrusted input is
// risky — ImageMagick and ffmpeg have a long CVE history. Run it in a
// sandbox: a locked-down container, no network, read-only root, dropped
// capabilities, and a memory limit.

Serving files without path traversal

The `+ path.sep` in the containment check is the detail that stops `/var/app/uploads-evil` passing a naive startsWith on `/var/app/uploads`.

Example · javascript
import path from 'node:path';
import fs from 'node:fs/promises';

const STORAGE_ROOT = '/var/app/uploads';

// ❌ Every one of these is traversable
res.sendFile(path.join(STORAGE_ROOT, req.params.filename));
res.sendFile(STORAGE_ROOT + '/' + req.query.file);
// ../../../etc/passwd
// ..%2f..%2f..%2fetc%2fpasswd          ← url encoded
// ....//....//etc/passwd               ← defeats a naive '../' replace
// /etc/passwd                          ← absolute path wins in path.join? no,
//                                        but path.resolve would

// ✅ Best: never accept a path at all. Look it up by id.
app.get('/api/files/:id', auth, async (req, res) => {
  const file = await db('files')
    .where({ id: req.params.id, user_id: req.user.id })   // authorization
    .first();
  if (!file) return res.status(404).json({ error: 'not_found' });

  // storage_key was generated by us at upload time: a uuid, no user input.
  const absolute = path.join(STORAGE_ROOT, file.storage_key);

  res.type(file.mime_type)
     .set('Content-Disposition',
          `attachment; filename="${sanitiseFilename(file.original_name)}"`)
     .sendFile(absolute);
});

// ✅ If you genuinely must accept a name, resolve and then VERIFY containment.
export function safeJoin(base, userPath) {
  const resolvedBase = path.resolve(base);
  const target = path.resolve(resolvedBase, userPath);

  // The separator matters: '/var/app/uploads-evil' also startsWith('/var/app/uploads')
  if (target !== resolvedBase && !target.startsWith(resolvedBase + path.sep)) {
    throw new ForbiddenError('path traversal');
  }
  return target;
}

// ...and check the resolved path AFTER following symlinks, or a symlink inside
// the directory points wherever it likes.
const real = await fs.realpath(safeJoin(STORAGE_ROOT, name));
if (!real.startsWith(path.resolve(STORAGE_ROOT) + path.sep)) {
  throw new ForbiddenError('symlink escapes storage root');
}

// And the header sanitiser, because a filename with a quote or newline in it
// is header injection:
const sanitiseFilename = (n) =>
  n.replace(/[^\w.\- ]/g, '_').slice(0, 100);

Template injection, and safe customisation

Replacing the engine with plain substitution is the only reliably safe way to accept user-authored templates — sandboxing a full template engine has a poor track record.

Example · javascript
// ❌ User input COMPILED as a template. This is remote code execution.
import Handlebars from 'handlebars';
const template = Handlebars.compile(user.emailTemplate);   // attacker-authored
const html = template({ user });

// In many engines the payload reaches the runtime:
//   {{#with "s" as |string|}}{{#with split as |conslist|}}...{{/with}}{{/with}}
//   {{constructor.constructor('return process.env')()}}
// Nunjucks, Pug, EJS, Jinja2, Twig and Freemarker all have known escapes.

// ✅ Templates come from your codebase. User input is a VALUE.
const template = Handlebars.compile(
  await fs.readFile('templates/invoice-email.hbs', 'utf8'));
const html = template({
  customerName: user.name,        // escaped by the engine on output
  total: formatMoney(invoice.totalCents),
});

// ✅ If users genuinely need customisation, give them a value allowlist
// with a logic-less engine and simple placeholder substitution.
const ALLOWED_VARS = ['customer_name', 'invoice_number', 'total', 'due_date'];

export function renderUserTemplate(templateText, values) {
  // Bound the input first
  if (templateText.length > 10_000) throw new BadRequestError('template too long');

  // Reject anything that is not a plain {{ placeholder }}
  const used = [...templateText.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g)].map((m) => m[1]);
  const unknown = used.filter((v) => !ALLOWED_VARS.includes(v));
  if (unknown.length) {
    throw new BadRequestError(`unknown variables: ${unknown.join(', ')}`);
  }

  // Anything that is not a recognised placeholder must not survive
  if (/\{\{[^}]*[^\w.\s}][^}]*\}\}/.test(templateText)) {
    throw new BadRequestError('only simple {{ variable }} placeholders are allowed');
  }

  // Plain substitution — no engine, no compilation, no evaluation.
  return templateText.replace(/\{\{\s*([\w.]+)\s*\}\}/g,
    (_, name) => escapeHtml(String(values[name] ?? '')));
}

// The principle: the engine never sees attacker-controlled template text.
// Substitution is a string operation, not an evaluation.

Discussion

  • Be the first to comment on this lesson.