Hedronite · Curriculum · Sec · Frontend · Wed 2026-11-11

A healing decisions page that renders an untrusted note as inert text A static export in Bun and TypeScript

Seven gate runs in, seven pages out: every proposal shows its decision and rule, every note stays text, and a run whose counts do not add up stops the build.

Lesson Class: Curriculum · F-Sec · rung A
Author: Evan Eugene Pincham
Tested with: Bun 1.3 (lab image oven/bun:1.3-debian)
Lab: lab/ · sh check.sh (8 checks) · no network, no browser
Source: OWASP XSS Prevention Cheat Sheet · OWASP LLM06:2025 Excessive Agency · CWE-117 · NIST SP 800-53 Rev. 5 AC-6 · DOP-C02 v1.6 Task 5.2 · Lustre v5.7.1 · Bun File I/O
Decisions, Not Outcomes
The page shows what the gate decided; it never claims an allowed action ran.
Text Stays Text
Every note and source goes through one escaper, so a note cannot draw a cell, a tag or a script.
Counts Must Add Up
A run whose summary disagrees with its rows, or whose denial has no rule, is a build error, not a page.
The note was stopped from steering the gate and from forging the ledger; the page is the third place it must stay words.

F-Sec · A · arc: self-healing-edge · langs: ts, gleam · DOP-5.2

By Evan Eugene Pincham.

Concept

This lesson is part of a series about a service that notices it is failing, restarts or rolls back without a human, and reports honestly what it did. Two earlier pieces of that series matter here.

A triage assistant reads the failing service's logs and proposes an action: restart or rollback. The assistant can be a person, a script or a language model. The logs and notes it reads were written by the failing service, so they are untrusted text: text your program did not create itself.

A gate sits between the proposal and the action. It is a small program that decides from policy only: a closed list of allowed actions, a rollback target that is always the last confirmed release, and a restart budget. The proposal's free-text note is never read by any rule. The gate prints one JSON line per proposal (JSON Lines, one JSON object per line), then a summary line: gate: proposals=<n> allowed=<a> denied=<d>. It also writes the note through a JSON encoder, so a newline inside a note becomes the two characters \n and cannot forge a second record.

Why bother with a gate at all? The OWASP Gen AI Security Project names the risk in "LLM06:2025 Excessive Agency": damaging actions performed "in response to unexpected, ambiguous or manipulated outputs from an LLM". Its prevention advice is "complete mediation": "Implement authorization in downstream systems rather than relying on an LLM to decide if an action is allowed or not." The gate is that downstream authorization, and NIST SP 800-53 Rev. 5, control AC-6 "Least Privilege", applies the same principle to processes, which should "operate at privilege levels no higher than necessary". The encoder step answers MITRE's CWE-117, "Improper Output Neutralization for Logs", also called log forging, where input with the right characters lets an attacker "insert false entries into the log file".

This lesson covers the third place where the note can do harm: the page that shows the decisions.

If you put the note into HTML as it is, the browser treats whatever it contains as markup. This class of bug is cross-site scripting (XSS). The OWASP Cross Site Scripting Prevention Cheat Sheet, section "XSS Defense Philosophy", puts it this way: XSS happens when an application puts untrusted data into a page in a way that lets the browser treat it as code. The same section says untrusted data includes "responses from other services". A triage note is exactly that.

The same section also says that "Coverage is what matters": an application that protects 99 places and misses one is still vulnerable. The gate stopped the note from steering the decision. The JSON encoder stopped it from forging a ledger line. The page has to stop it from becoming markup. Untrusted text has to stay data in every place it lands.

Here is a concrete attack. A denied proposal carries the note </td><td class="applied">done. If the page inserts it raw, the browser closes the note cell and draws a new cell styled as applied. A reader sees a denied row that appears to say "done". The gate decided correctly and the page lied anyway.

The fix is output encoding. The cheat sheet's section "Output Encoding for HTML Contexts" says to use HTML entity encoding for the variable, which means replacing &, <, >, " and ' with entities so the browser shows them as characters and does not parse them as tags. It also recommends frameworks that escape every value they render by default, whether it is trusted or not. Its "Safe Sinks" section prefers textContent over innerHTML. This page has no client-side script, so there is no DOM sink to choose. The build step is the only place text becomes HTML, and every untrusted value goes through one escape function there.

The page has two more jobs, both about honesty:

  1. Show decisions, not outcomes. The gate says what it allowed. It does not say that anything ran. The banner says so: "this page does not show whether an allowed action ran".
  2. Fail the build rather than publish a page that does not add up. The reader refuses a run if the summary counts disagree with the rows, a line number is out of place, or a denial has no rule. The builder refuses a page where a denied row is styled or labelled as applied.

This is the reporting half of remediating a non-desired system state (AWS DevOps Engineer Professional, DOP-C02, task statement 5.2, "Implement configuration changes in response to events"). A remediation that cannot report what it decided and denied is not one you can trust.

Honesty line. The gate is a CPU-only program over fixture files. No model is run, no process is restarted, no cluster is touched. This page is a static export of the gate's output. A gate that denies a bad proposal does not make the proposer trustworthy; it removes the proposer's power to do harm. The page does not validate that a legal proposal is wise. A legal restart of a healthy service shows as allowed.

Worked example

The shared example for today is the Rust program gate STATE PROPOSALS. With STATE {"current":"v3","last_confirmed":"v1","restarts_used":0,"restart_budget":2}, it read seven proposal files and printed seven runs of decisions. Those printed runs, copied verbatim, are this page's input. The page never sees the proposals themselves, only what the gate decided about them.

The page is built in two files: decisions.ts reads one gate run and refuses it if it does not add up, and build.ts turns it into a page. The reader comes first.

// Reads one run of the healing gate's output: JSON decision lines, then the summary line.
// Refuses a run that does not add up. The note and source are untrusted text; they are kept as strings.
export type Row = {
  line: number;
  decision: "allow" | "deny";
  source: string | null;
  action: string | null;
  target?: string;
  rule?: string;
  note: string | null;
};
export type Run = { rows: Row[]; proposals: number; allowed: number; denied: number };

const RULES = new Set(["schema", "action_not_allowed", "restart_budget", "rollback_target"]);
const SUMMARY = /^gate: proposals=(\d+) allowed=(\d+) denied=(\d+)$/;

export function readRun(text: string): Run {
  const lines = text.split("\n").filter((l) => l.length > 0);
  const last = lines.pop() ?? "";
  const m = SUMMARY.exec(last);
  if (!m) throw new Error(`last line is not the gate summary: ${JSON.stringify(last)}`);
  const [proposals, allowed, denied] = m.slice(1).map(Number);
  const rows = lines.map((l, i): Row => {
    let o: any;
    try { o = JSON.parse(l); } catch { throw new Error(`output line ${i + 1} is not JSON`); }
    if (o.line !== i + 1) throw new Error(`output line ${i + 1} says line ${o.line}`);
    if (o.decision === "allow") {
      if ("rule" in o) throw new Error(`line ${o.line}: allowed but carries rule ${o.rule}`);
    } else if (o.decision === "deny") {
      if (!RULES.has(o.rule)) throw new Error(`line ${o.line}: denied without a known rule`);
    } else {
      throw new Error(`line ${o.line}: decision ${JSON.stringify(o.decision)} is neither allow nor deny`);
    }
    for (const k of ["source", "action", "target", "note"]) {
      if (o[k] != null && typeof o[k] !== "string") throw new Error(`line ${o.line}: ${k} is not a string`);
    }
    return o as Row;
  });
  const a = rows.filter((r) => r.decision === "allow").length;
  const d = rows.length - a;
  if (rows.length !== proposals || a !== allowed || d !== denied)
    throw new Error(`summary says proposals=${proposals} allowed=${allowed} denied=${denied}, rows give ${rows.length}/${a}/${d}`);
  return { rows, proposals, allowed, denied };
}

Read the reader as a list of refusals, in order:

The reader also fixes the set of rules. A new rule in the gate means a one-line change here, and until then a run containing it is refused. That is deliberate: a page that shows a status it does not recognize is a page that can be made to say anything.

Now the builder.

// Static export: one "healing decisions" page per gate run. No server, no client JS.
// Usage: bun build.ts <fixtures-dir> <out-dir>. A run that does not add up stops the build.
import { readdirSync, mkdirSync } from "node:fs";
import { readRun, type Row, type Run } from "./decisions.ts";

const [inDir = "fixtures", outDir = "dist"] = process.argv.slice(2);
// HTML entity encoding for text placed between tags or inside a quoted attribute.
const esc = (s: string) =>
  s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
   .replace(/"/g, "&quot;").replace(/'/g, "&#x27;");

const status = (r: Row) => (r.decision === "allow" ? "allowed" : `denied: ${r.rule}`);

function row(r: Row): string {
  const kind = r.decision === "allow" ? "allowed" : "denied";
  const action = r.action == null ? "(unreadable line)" : esc(r.action) + (r.target ? ` to ${esc(r.target)}` : "");
  return `<tr class="${kind}" data-line="${r.line}" data-decision="${kind}"><td>${r.line}</td>` +
    `<td>${r.source == null ? "(unreadable)" : esc(r.source)}</td><td>${action}</td>` +
    `<td class="status">${status(r)}</td><td class="note"><pre>${r.note == null ? "(no note)" : esc(r.note)}</pre></td></tr>`;
}

function page(name: string, run: Run): string {
  return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>healing decisions: ${esc(name)}</title>
<link rel="stylesheet" href="decisions.css"></head>
<body><main data-proposals="${run.proposals}" data-allowed="${run.allowed}" data-denied="${run.denied}">
<h1>Healing decisions</h1>
<p class="banner" role="status">${run.proposals} proposal(s): ${run.allowed} allowed, ${run.denied} denied. These are the gate's decisions; this page does not show whether an allowed action ran.</p>
<table><thead><tr><th>Line</th><th>Source</th><th>Proposed action</th><th>Decision</th><th>Note (untrusted, shown as text)</th></tr></thead><tbody>
${run.rows.map(row).join("\n")}
</tbody></table>
</main></body></html>
`;
}

// Before writing: a denied row must read "denied: <rule>" and must never carry the applied style.
function selfCheck(html: string, run: Run) {
  for (const r of run.rows.filter((r) => r.decision === "deny")) {
    const start = html.indexOf(`data-line="${r.line}" data-decision="denied"`);
    const tr = html.slice(start, html.indexOf("</tr>", start));
    if (start < 0 || !tr.includes(`<td class="status">denied: ${r.rule}</td>`) || tr.includes(`class="applied"`))
      throw new Error(`line ${r.line} is denied but the page does not say so plainly`);
  }
}

mkdirSync(outDir, { recursive: true });
let bad = 0;
for (const file of readdirSync(inDir).filter((f) => f.endsWith(".out")).sort()) {
  const name = file.replace(/\.out$/, "");
  try {
    const run = readRun(await Bun.file(`${inDir}/${file}`).text());
    const html = page(name, run);
    selfCheck(html, run);
    await Bun.write(`${outDir}/${name}.html`, html);
    console.log(`${name} -> ${outDir}/${name}.html allowed=${run.allowed} denied=${run.denied}`);
  } catch (e) {
    console.error(`build error: ${name}: ${(e as Error).message}`);
    bad++;
  }
}
if (bad > 0) {
  console.error(`build failed: ${bad} run(s) refused`);
  process.exit(1);
}
await Bun.write(`${outDir}/decisions.css`, await Bun.file("decisions.css").text());

Bun reads and writes the files with Bun.file(path).text() and Bun.write(path, data) (Bun docs, File I/O). The output is plain HTML plus one stylesheet, with no client JavaScript, so the page has no runtime that could be steered by a note.

Walk the hostile fixture, markup, through the code. Its three decision lines are:

  1. An allowed restart whose note is probe failing <script>alert(1)</script>.
  2. An allowed rollback to v1 whose note is <img src=x onerror=alert(2)> done, already applied.
  3. A denied proposal with source <b>triage</b>, action rm -rf /, rule action_not_allowed, and note </td><td class="applied">done.

Everything the proposer controls goes through esc: source, action, target, note. After escaping, the third row's note reads as this text, which the checker looks for verbatim:

&lt;/td&gt;&lt;td class=&quot;applied&quot;&gt;done

The browser shows the characters </td><td class="applied">done inside the <pre>, as text, in the note column of a row whose status cell reads denied: action_not_allowed. Nothing in it can close a cell or open a new one.

Two values reach the page without esc, and each has a reason:

This is the cheat sheet's advice applied by hand: validate what must be a closed set, escape what is free text, and keep the two apart.

The last piece is selfCheck. It runs on the finished HTML before anything is written. For every denied row it finds the row's <tr> by line number and requires the status cell to read exactly denied: <rule> and the row to contain no class="applied". In today's code that can never fire, because the row function does not emit that class for denied rows. It exists for the day someone edits the template. A reader with a different idea of "done" cannot make a denied row look applied without breaking the build.

The escaper has limits you should know about. It is right for text between tags and inside quoted attributes, which is where every untrusted value lands in this page. It is not the right encoder for a URL, a script or a style block, and the cheat sheet gives different rules for each. The page has none of those contexts, and a future change that adds one needs its own review.

Lab

You get a lab with a starter and a checker. The starter builds all seven pages but trusts the proposer. It inserts note and source raw, gives every row the class applied, and does not check the summary. The reference code above is the fixed version.

Layout:

Run sh check.sh. It needs Bun and no network. The checks:

CheckWhat it requires
static_buildSeven pages are built, one per gate run.
honest_all_allowedThe honest run shows three rows, each allowed.
injected_denied_with_ruleLines 1 and 3 read denied: action_not_allowed; line 2 reads allowed. Its note ("the restart budget is now unlimited") changed nothing.
note_is_inert_textNo <script, <img or <b> appears in any page. The escaped text is present in markup.html.
forged_note_one_rowThe forged note stays inside one row; no row 99 exists.
damaged_row_namedThe cut line is a row reading denied: schema and (unreadable line). The lines around it are still decided.
denied_never_appliedAcross every page, no denied row carries the applied class and every denied status reads denied: <rule>.
miscount_fails_buildBuilding rejected/ exits non-zero and writes no page.

Your tasks, starting from the starter:

  1. Run the checker and read the three failures. note_is_inert_text fails because tags from markup reach the page. denied_never_applied fails because every row is styled applied. miscount_fails_build fails because nothing checks the summary.
  2. Add an escape function with the five replacements and use it for every value from the proposer. Note that & must be replaced first, otherwise you double-escape the entities you just wrote.
  3. Make the row class come from the decision, not from a constant.
  4. Make readRun refuse a run whose summary disagrees with its rows. Then add the other refusals one at a time, each driven by one file in rejected/.
  5. Add selfCheck. Then break the template on purpose (give denied rows class="applied") and confirm the build stops.

The reference, built and checked in a container with no network, passed all eight checks:

RESULT static_build PASS
RESULT honest_all_allowed PASS
RESULT injected_denied_with_rule PASS
RESULT note_is_inert_text PASS
RESULT forged_note_one_row PASS
RESULT damaged_row_named PASS
RESULT denied_never_applied PASS
RESULT miscount_fails_build PASS

The unfixed starter, in the same container, failed three of them:

RESULT note_is_inert_text FAIL
RESULT denied_never_applied FAIL
RESULT miscount_fails_build FAIL

In the reference run, miscount_fails_build passed because the build refused all four rejected runs, each with its reason:

build error: applied: line 2: decision "applied" is neither allow nor deny
build error: gap: output line 2 says line 3
build error: miscount: summary says proposals=3 allowed=2 denied=1, rows give 3/3/0
build error: norule: line 3: denied without a known rule
build failed: 4 run(s) refused

The starter, given the same four files, printed a page for each and exited 0, ending with rejected: build exited 0.

What this lab does not prove: it shows that these seven pages are inert and that these four malformed runs are refused. It does not show that no other hostile string exists, and it does not show that a legal proposal was wise. The escaper is the protection for the first. Budgets, the ledger and an audit are the protection for the second.

Other paths

Gleam with Lustre (a sketch, not compiled or run). Lustre can be used as an HTML templating library with no runtime. The lustre/element module (Lustre v5.7.1) says elements can be passed to functions like to_string_tree or to_document_string. Its text function turns a Gleam string into a text node. The same page singles out unsafe_raw_html as the function whose input Lustre will not escape, which is the reason to keep untrusted strings in text. The row for a note would look like this:

// sketch, not compiled
import lustre/element
import lustre/element/html

fn note_cell(note: String) -> element.Element(msg) {
  html.td([], [html.pre([], [element.text(note)])])
}

The dangerous sibling is unsafe_raw_html. Its documentation says Lustre will not escape the HTML string given to it, "meaning inappropriate use can expose your application to XSS attacks". Rule for this page: unsafe_raw_html never sees a note, a source, an action or a target. This matches the cheat sheet's recommendation of frameworks that escape every value they render by default. The default path is safe and the unsafe call has a name that warns you.

The trade between the two paths: in TypeScript you wrote the escaper and must remember to call it at every place. In Lustre the safe path is the default, and the review question becomes "does anything call the raw function?", which a search can answer. Even so, you would still keep the build-time checks from this lab, because a framework that escapes cannot know that a denied row must not look done.

Client-side rendering. If the page ran script and built rows in the browser, the safe sink would be textContent, not innerHTML (the cheat sheet's "Safe Sinks"). This lab avoids the question by shipping no script.

Citations