---
title: 'Template and Expression Injection'
source: 'https://academia.sh/en/courses/secure-coding/template-and-expression-injection'
course: 'Secure Coding'
language: en
updated: '2026-09-26T03:02:39+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Measures the number of names the template engine can resolve in the evaluation environment'
    - 'Separates, in an inventory, templates that take user data as a value from those that take it as an expression'
    - 'Compares the effect of narrowing the environment versus fixing the body on the total evaluation surface'
---

# Template and Expression Injection

Measuring server-side evaluation risk as a surface: the number of names resolvable in the evaluation environment is counted with probes generated from the environment itself, a twelve-template inventory separates how many take user data only as a value versus as an expression, and the total evaluation surface of templates whose body comes from outside is compared across three setups.

The previous lesson assumed the query text was fixed: the developer writes the skeleton,
and the value comes from outside. Notification texts, report headers, and field labels do
not carry that assumption. Their body is a template, and some of these templates are read
at runtime from a configuration record or from a user field.

This lesson counts templates. The quantity measured is not whether the text gets corrupted
but the **evaluation surface**: how many names can be resolved while the template is
processed. If a template takes user data only as a **value**, the surface is limited to the
declared fields; if it takes the data as an **expression**, the surface is the entire
environment.

## Two Engines, One Environment

The model contains two engines. The **value engine** substitutes only the declared names
and never rescans the result. The **expression engine** resolves a dotted path in the
environment; it has no notion of declaration. The environment is the set of objects
accessible while the template is processed.

**IO9 (assumption):** the environment and the engines are a model. The surface is the
number of leaf names resolvable in the environment by a dotted path; how sensitive the
value a name carries is, is not measured.

```js
// engine.mjs — two template engines and the evaluation environment (fictional model)
// Value engine: substitutes only the declared names; the result is never rescanned.
export const valueEngine = (template, env, declared) =>
  template.replace(/\{([\w.]+)\}/g, (t, path) =>
    declared.includes(path) ? String(path.split(".").reduce((o, k) => o?.[k], env)) : t);

// Expression engine: resolves a dotted path in the environment; it has no notion of declaration.
export const expressionEngine = (template, env) =>
  template.replace(/\{([\w.]+)\}/g, (t, path) => {
    const v = path.split(".").reduce((o, k) => o?.[k], env);
    return v === undefined || typeof v === "object" ? t : String(v);
  });

// Accessible name: the list of leaf names resolvable in the environment by a dotted path.
export const accessible = (o, prefix = "") =>
  o === null || typeof o !== "object" ? [prefix]
    : Object.entries(o).flatMap(([k, v]) => accessible(v, prefix ? `${prefix}.${k}` : k));

export const WIDE = {
  subscriber: { name: "A. Yilmaz", number: "A-100432", tariff: "residential" },
  invoice: { period: "2026-03", amount: 412, dueDate: "2026-04-15" },
  settings: { region: "north", version: "3", logLevel: "info", serverName: "measurement-1" },
  connection: { storeName: "measurement", roleName: "report_reader", timeout: 30 },
  system: { workingDirectory: "/srv/measurement", processId: 4120 },
};
export const NARROW = { subscriber: WIDE.subscriber, invoice: WIDE.invoice };
export const DECLARED = ["subscriber.name", "subscriber.number", "invoice.period", "invoice.amount", "invoice.dueDate"];
```

The wide environment represents an application handing its template processor everything
within reach: alongside subscriber and invoice data, settings, connection information, and
process information. The narrow environment carries only the two objects the notification
requires.

The existence of the expression engine is not a flaw but a request. Letting the
administrator edit the notification text, letting the region name enter the report header,
and generating the field label from a template are genuine requirements of the work. The
evaluation surface is the numeric counterpart of that requirement: making the text editable
from outside also makes the environment that processes it queryable from outside. What must
be measured is the size of that trade-off.

## Evaluation Surface

When the surface is counted, the probe set is not written by hand; it is **generated from
the environment itself**. A single placement is tried for every accessible name, and how
many resolve is counted. This way, the measurement updates itself whenever the environment
changes.

```js
// surface.mjs — evaluation surface: how many names can be resolved in the environment
import { valueEngine, expressionEngine, accessible, WIDE, NARROW, DECLARED } from "./engine.mjs";

// The probe set is generated from the environment itself: a single placement per accessible name.
const probes = (env) => accessible(env).map((path) => [path, `{${path}}`]);

console.log(`declared names: ${DECLARED.length}\n`);
console.log(`${"environment / engine".padEnd(23)}${"accessible names".padStart(19)}${"resolved".padStart(10)}${"undeclared".padStart(12)}`);
for (const [name, env, engine] of [["wide / expression", WIDE, "expression"], ["wide / value", WIDE, "value"],
    ["narrow / expression", NARROW, "expression"], ["narrow / value", NARROW, "value"]]) {
  const s = probes(env);
  const apply = (x) => (engine === "expression" ? expressionEngine(x, env) : valueEngine(x, env, DECLARED));
  const resolved = s.filter(([, x]) => apply(x) !== x);
  const outside = resolved.filter(([path]) => !DECLARED.includes(path)).length;
  console.log(name.padEnd(23) + String(s.length).padStart(19) + String(resolved.length).padStart(10) +
    String(outside).padStart(12));
}
const outsideWide = accessible(WIDE).filter((y) => !DECLARED.includes(y));
console.log(`\nnames left undeclared in the wide environment: ${outsideWide.length}`);
console.log(`top-level name set: wide ${Object.keys(WIDE).join(" ")} / narrow ${Object.keys(NARROW).join(" ")}`);
```

```
declared names: 5

environment / engine      accessible names  resolved  undeclared
wide / expression                       15        15          10
wide / value                            15         5           0
narrow / expression                      6         6           1
narrow / value                           6         5           0

names left undeclared in the wide environment: 10
top-level name set: wide subscriber invoice settings connection system / narrow subscriber invoice
```

The four rows separate two independent defenses. **Narrowing the environment** brings the
accessible names down from fifteen to six; the expression engine still resolves all of
them, but there are no names left to resolve. **Fixing the engine to value mode** brings
the resolved names down to the declared set regardless of the environment: even in the
wide environment, the number of names resolved outside the declared set is zero.

The last row gives the combined meaning of the two. In the narrow environment, the value
engine resolves five names, the expression engine six — the single name in between is
`subscriber.tariff`, a field that does not appear in the declared set. The surface is not
zeroed out; **it narrows and stays countable**.

## Template Inventory

The surface is measured per template, and one thing alone determines it: where the
template's **body** comes from. If the body is fixed, the developer wrote the names to be
resolved, and no name comes from outside. If the body is read from a configuration record
or a user field, every resolvable name is that template's surface.

**IO10 (assumption):** a template whose body is fixed is counted as having zero surface.
What is measured is not who wrote the template but **where it is read from**.

```js
// inventory.mjs — template inventory (fictional model) and total evaluation surface
import { accessible, WIDE, NARROW, DECLARED } from "./engine.mjs";

const ENV = { wide: WIDE, narrow: NARROW };
// [service, template, body source, engine, environment]
const TEMPLATE = [
  ["notification", "invoiceIssued", "fixed", "value", "narrow"],
  ["notification", "dueDateApproaching", "fixed", "value", "narrow"],
  ["notification", "readingMissing", "fixed", "value", "narrow"],
  ["invoice", "statementHeader", "fixed", "value", "narrow"],
  ["invoice", "descriptionLine", "fixed", "value", "narrow"],
  ["work-order", "taskText", "fixed", "value", "narrow"],
  ["work-order", "closingNote", "fixed", "value", "narrow"],
  ["notification", "campaignText", "configuration", "expression", "wide"],
  ["report", "header", "configuration", "expression", "wide"],
  ["report", "footer", "configuration", "value", "narrow"],
  ["report", "summaryHeading", "user", "expression", "wide"],
  ["field", "labelText", "user", "expression", "wide"],
];
const NEW = [["report", "regionHeading", "configuration"], ["notification", "reminder", "configuration"],
  ["field", "formHeading", "configuration"]];

// Surface: if the body is fixed, the set of names to be resolved is already inside the template.
const surface = ([, , source, engine, e]) =>
  source === "fixed" ? 0 : engine === "expression" ? accessible(ENV[e]).length : DECLARED.length;
const total = (l) => l.reduce((t, s) => t + surface(s), 0);
const count = (l, i, v) => l.filter((s) => s[i] === v).length;

console.log(`${TEMPLATE.length} templates, all take user data`);
console.log(`  engine: value ${count(TEMPLATE, 3, "value")}, expression ${count(TEMPLATE, 3, "expression")}`);
console.log(`  body source: fixed ${count(TEMPLATE, 2, "fixed")}, configuration ` +
  `${count(TEMPLATE, 2, "configuration")}, user ${count(TEMPLATE, 2, "user")}\n`);

const SETUP = [
  ["current state", (s) => s],
  ["pattern: value engine + narrow environment", ([a, b, k]) => [a, b, k, "value", "narrow"]],
  ["pattern: body fixed too", ([a, b]) => [a, b, "fixed", "value", "narrow"]],
];
console.log(`${"setup".padEnd(44)}${"templates".padStart(11)}${"external body".padStart(15)}${"total surface".padStart(15)}`);
for (const [name, d] of SETUP) {
  const l = TEMPLATE.map(d), y = NEW.map(([a, b, k]) => d([a, b, k, "expression", "wide"]));
  console.log(name.padEnd(44) + String(l.length).padStart(11) +
    String(l.length - count(l, 2, "fixed")).padStart(15) + String(total(l)).padStart(15));
  console.log(`  after three new templates: ${l.length + y.length} templates, surface ${total([...l, ...y])}`);
}
```

```
12 templates, all take user data
  engine: value 8, expression 4
  body source: fixed 7, configuration 3, user 2

setup                                         templates  external body  total surface
current state                                        12              5             65
  after three new templates: 15 templates, surface 110
pattern: value engine + narrow environment           12              5             25
  after three new templates: 15 templates, surface 40
pattern: body fixed too                              12              0              0
  after three new templates: 15 templates, surface 0
```

All twelve templates take user data; that alone carries no weight. The split is that eight
take the data only as a value, four as an expression. And the real split is in the body
source: seven templates have a fixed body, three are read from configuration, two directly
from a user field.

The total surface is sixty-five. All of that number comes from five templates; the
remaining seven contribute nothing at all. **The surface grows not with the number of
templates but with the number of templates whose body comes from outside.**

Two of the five templates are a separate class. The report summary heading and the field
label text take their bodies not from a configuration record but directly from a user
field: the heading the user writes is processed as the template's body. The number of
people who can edit a body coming from configuration is limited, and that record can pass
through an approval flow; there is no such limit for a body coming from a user field. The
surface of the two is the same — fifteen names each — but the number of people who can
reach that surface is not.

The second setup gives the pattern's payoff: when all templates are moved to the value
engine and the narrow environment, the surface drops from sixty-five to twenty-five. It
does not drop to zero, because the body is still read from outside and the five declared
names can be resolved in every one of them. In the third setup, the body is fixed too, and
the surface is zeroed out — but in this setup the administrator can no longer edit the
notification text. That is the cost, and it needs to be written down.

The sub-rows give the third column. When three new templates are added, the surface in the
current state rises from sixty-five to one hundred ten: **each new template adds fifteen
names**. Under the pattern, the same three templates raise the surface from twenty-five to
forty — five names each. In the fixed-body pattern, three new templates do not change the
surface at all.

Comparing the three setups says one more thing. In the second setup, while the surface
drops to twenty-five, none of the templates lose their function: the notification text can
still be edited by the administrator, only the set of names the edited text can resolve
narrows. In the third setup, the twenty-five units gained are paid for with a lost
capability. **The only way to zero out the surface is to stop the text from being editable
from outside**, and that is not a security decision but a product decision.

## Body Review

For templates whose body cannot be fixed, a third defense remains: passing the body itself
through an allowlist. This means reading the body, extracting the names that appear in it,
and rejecting the template if any name is not in the list. Its gain and its cost are read
from the same number.

```js
// body-review.mjs — passing an externally sourced template body through an allowlist
import { accessible, WIDE, DECLARED } from "./engine.mjs";

// Template bodies read from configuration (fictional): all legitimate texts written by the administrator.
const BODY = [
  "Dear {subscriber.name}, your invoice for the {invoice.period} period is ready.",
  "Due date {invoice.dueDate}.",
  "{invoice.amount} units were charged for subscription {subscriber.number}.",
  "Monthly statement for region {settings.region}.",
  "Generated via server {settings.serverName}, version {settings.version}.",
];
const names = (s) => [...s.matchAll(/\{([\w.]+)\}/g)].map((m) => m[1]);
const passes = (s, allowlist) => names(s).every((a) => allowlist.includes(a));

const ALLOWLIST = [["declared name set", DECLARED],
  ["declared + settings.region", [...DECLARED, "settings.region"]],
  ["no body review", accessible(WIDE)]];

console.log(`${BODY.length} template bodies, ${new Set(BODY.flatMap(names)).size} distinct names used\n`);
console.log(`${"allowlist".padEnd(28)}${"length".padStart(8)}${"passing bodies".padStart(16)}${"blocked legitimate bodies".padStart(27)}${"surface".padStart(9)}`);
for (const [name, allowlist] of ALLOWLIST) {
  const passing = BODY.filter((s) => passes(s, allowlist)).length;
  console.log(name.padEnd(28) + String(allowlist.length).padStart(8) + String(passing).padStart(16) +
    String(BODY.length - passing).padStart(27) + String(allowlist.length).padStart(9));
}
```

```
5 template bodies, 8 distinct names used

allowlist                     length  passing bodies  blocked legitimate bodies  surface
declared name set                  5               3                          2        5
declared + settings.region         6               4                          1        6
no body review                    15               5                          0       15
```

The three rows give a single curve: every time the allowlist grows longer, one legitimate
body is rescued and the surface grows by one. With a list of five names, three bodies pass,
and two legitimate bodies are blocked. When the region name is added to the list, four
pass, and the surface rises to six. When the review is removed, all five pass, and the
surface becomes fifteen.

One case remains outside the review's coverage. In the two templates whose body is read
directly from a user field, a text carrying curly braces can be legitimate — a unit of
measure can be written in a report heading, or a field code in a field label. There, the
allowlist rejects the legitimate heading and the cost becomes visible; the decision to
read the body from a user field is a decision the allowlist cannot resolve.

This is the allowlist from the first lesson applied to the template body, and it carries
the same property: a legitimate name not in the list is rejected. The difference is what
is measured — there, the uncovered points were counted; here, it is **the number of
resolvable names**. The length of the allowlist is directly the surface itself; that is
why every request for a new name is a surface decision and needs to be written down
somewhere.

## Summary

- The quantity measured is the evaluation surface: the number of names resolvable in the
  environment while the template is processed. The probe set is not written by hand; it is
  generated from the environment itself.
- In the wide environment, the expression engine resolved 15 of 15 names, and 10 of those
  were outside the declared set. Narrowing the environment brought the accessible names
  down to 6; fixing the engine to value mode brought the names resolved outside the
  declared set to 0 even in the wide environment. The two defenses are independent.
- All twelve templates take user data; 8 only as a value, 4 as an expression. The
  body-source split is decisive: 7 fixed, 3 from configuration, 2 from a user field.
- The total surface is 65, and all of it comes from the 5 templates whose body comes from
  outside. The value engine and the narrow environment bring the surface down to 25; fixing
  the body too brings it down to 0. The cost of the third option is that the notification
  text can no longer be edited by the administrator.
- For a template whose body cannot be fixed, the third defense is passing the body through
  an allowlist: with a list of 5 names, 3 of the 5 bodies passed and 2 legitimate bodies
  were blocked; when the list grew to 6, 4 passed and the surface became 6. The length of
  the allowlist is directly the surface itself.
- With three new templates, the surface rose from 65 to 110 in the current state, from 25
  to 40 under the pattern, and stayed at 0 in the fixed-body setup. The surface grows not
  with the number of templates but with the number of templates whose body comes from
  outside.

## Next Step

The template body was text, and its coming from outside grew the surface. The same
question is asked for inputs that are not text. The field application uploads a photo and
a measurement file at work-order closure; the subscriber portal accepts a file for a
meter-reading printout. This input has no contract — it is a byte sequence, and everything
said about it is the client's claim. The next lesson counts the upload flow as a layer of
checks: how many of the type, size, name, and storage-location checks are present, which
check alone is not enough, and how many points exposing the storage location opens up.
