fix(output-encoding): ask about the server half, and say what the family cannot decide

The census recorded this family completing 44 questions on a Node application and finding
nothing, which is what a client-only vocabulary must do there: all six declared sinks were DOM
sinks. NodeGoat's cross-site scripting is server-side.

Decision: the family asks about a value reaching a rendering call, and states in its own
declaration that it cannot decide template-mediated escaping. Whether res.render is safe depends
on the engine's autoescape setting and on the template's markup, neither of which is at the
call, so res.render is not a sink and the family's limits field says so. A new CWE-79 sink
covers res.write, res.end, res.send and the response.* spellings; res.json stays out because an
application/json body is not a document.

Template auto-escaping is claimed where its evidence is, as a configuration defect:
swig.setDefaults, nunjucks.configure and marked.setOptions are security settings now.

It establishes a finding: app/routes/research.js:25, model_entailed,
`req.query.url + req.query.symbol -> res.write(body)`. The handler fetches a URL the caller
chooses and writes the response body into a text/html response, so the document served from
this origin is the attacker's. The record states how coarse the step is: the path from url to
body runs through the client's callback parameter, which the graph treats as reachable from the
call's arguments, so the conclusion rests on the call's semantics rather than on what the graph
established. Five of seven families now establish findings.

Four defects were found on the way, three of them false positives this change would otherwise
have shipped.

- The permissive-value set was shared across every setting, while a boolean's polarity belongs
  to the setting: true leaves the door open in cors({origin: true}) and closes it in
  swig.setDefaults({autoescape: true}). Adding the template engines would have reported the fix
  as the defect on the line that offers it. ConfigSpec now carries per-setting values, and
  Jinja2's Environment moved out of Python's shared list for the same latent reason.
- config.sc matched a dotted setting on its trailing segment alone, so marked.setOptions would
  have claimed an editor's setOptions. dominance.sc already required the qualifier.
- taint.sc asked its flow query of every argument including the receiver at index 0. On a
  response sink the receiver is the response object, so res.end() -- which takes no data -- was
  reported exactly like the res.write(body) two lines above it. Flows now use the real
  arguments, as arity already did.
- The arbiter sorted a file's settings by line number as TEXT, so line 126 preceded line 78 and
  which setting a file reported depended on the spelling of its line numbers.

Template escaping disabled at server.js:135 is detected, measured model_entailed against the
real query and the real arbiter, and still not reported: the arbiter returns one verdict per
question and that file has an earlier permissive setting. Task 14.6 carries it rather than
hiding it.

1,415 tests pass, thirteen skipped; nine Joern-gated query tests pass in the image.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-09-19 20:38:39 +02:00
co-authored by Claude Opus 5
parent 57f5a3f0dc
commit f31ef862a7
14 changed files with 628 additions and 14 deletions
+105
View File
@@ -191,3 +191,108 @@ def test_a_literal_destination_is_not_an_untrusted_destination(tmp_path: Path) -
flows = [row for row in (payload.get("0") or []) if row.get("sink")]
assert flows, "the request flowing into the client was not reported at all"
assert all("needle" in str(row["sink"]) for row in flows), f"a client called with a literal was reported: {flows}"
@pytest.mark.skipif(_joern() is None, reason="joern is not installed on this machine")
def test_a_response_body_write_is_an_output_encoding_sink(tmp_path: Path) -> None:
"""A request value written straight into a response body is reported; the JSON form is not.
Every sink this family shipped with was a DOM sink, so on a Node application it completed its questions
and could not have found anything: the census measured that on NodeGoat, 44 questions and no findings.
`res.json` stays out because it sets an application/json content type, and that exclusion is asserted here
rather than left to the reader of the fact table.
"""
from openultrasast.cpg.backend import extract_payload
from openultrasast.model.specs import taint_specs
frontend = __import__("shutil").which("jssrc2cpg")
if frontend is None:
pytest.skip("jssrc2cpg is not installed")
sinks = taint_specs(language="javascript")["output_encoding"].sinks
assert "res.write" in sinks, "the shipped vocabulary no longer holds the sink this control is about"
assert "res.json" not in sinks, "res.json is a JSON content type, not a rendered document"
source = tmp_path / "render.js"
source.write_text(
"function handler(req, res) {\n"
" const name = req.query.name;\n"
" res.json({ echoed: name });\n"
' res.write("<h1>" + name + "</h1>");\n'
" return res.end();\n"
"}\n"
)
cpg = tmp_path / "cpg.bin"
built = subprocess.run([frontend, str(tmp_path), "-o", str(cpg)], capture_output=True, text=True, timeout=600, check=False)
if not cpg.is_file():
pytest.skip(f"could not build a sample cpg: {(built.stderr or '')[-200:]}")
requests = tmp_path / "requests.json"
requests.write_text(json.dumps({"0": {"sources": "req.query", "sinks": ",".join(sinks), "function": "handler"}}))
done = subprocess.run(
[
_joern() or "joern",
"--script",
str((QUERIES / "taint.sc").resolve()),
"--param",
f"cpgFile={cpg}",
"--param",
f"requestsFile={requests}",
],
capture_output=True,
text=True,
timeout=900,
check=False,
)
assert done.returncode == 0, f"taint.sc did not run: {(done.stderr or done.stdout or '')[-500:]}"
payload = extract_payload(done.stdout or "")
assert payload is not None, "taint.sc produced no parseable payload"
flows = [row for row in (payload.get("0") or []) if row.get("sink")]
assert flows, "a request value written into the response body was not reported"
assert all("res.json" not in str(row["sink"]) for row in flows), f"the JSON form was reported: {flows}"
@pytest.mark.skipif(_joern() is None, reason="joern is not installed on this machine")
def test_a_qualified_setting_needs_its_receiver(tmp_path: Path) -> None:
"""`marked.setOptions` must not be answered by an unrelated `setOptions`.
The setting matcher accepted a dotted spec's trailing segment alone, which was harmless while every
shipped setting was a bare name and became load-bearing when the template engines joined the table: an
editor's `setOptions` would have been read as a template engine's.
"""
from openultrasast.cpg.backend import extract_payload
frontend = __import__("shutil").which("jssrc2cpg")
if frontend is None:
pytest.skip("jssrc2cpg is not installed")
source = tmp_path / "setup.js"
source.write_text(
"function configure(editor) {\n editor.setOptions({ readOnly: false });\n marked.setOptions({ sanitize: false });\n}\n"
)
cpg = tmp_path / "cpg.bin"
built = subprocess.run([frontend, str(tmp_path), "-o", str(cpg)], capture_output=True, text=True, timeout=600, check=False)
if not cpg.is_file():
pytest.skip(f"could not build a sample cpg: {(built.stderr or '')[-200:]}")
requests = tmp_path / "requests.json"
requests.write_text(json.dumps({"0": {"settings": "marked.setOptions", "function": "configure"}}))
done = subprocess.run(
[
_joern() or "joern",
"--script",
str((QUERIES / "config.sc").resolve()),
"--param",
f"cpgFile={cpg}",
"--param",
f"requestsFile={requests}",
],
capture_output=True,
text=True,
timeout=900,
check=False,
)
assert done.returncode == 0, f"config.sc did not run: {(done.stderr or done.stdout or '')[-500:]}"
payload = extract_payload(done.stdout or "")
assert payload is not None, "config.sc produced no parseable payload"
settings = [str(row.get("setting", "")) for row in (payload.get("0") or []) if row.get("setting")]
assert any("marked.setOptions" in row for row in settings), f"the template engine's setting was missed: {settings}"
assert all("editor.setOptions" not in row for row in settings), f"an unrelated setOptions was claimed: {settings}"