Silent JS compile fallback: a syntax error becomes a stage that returns its own source as a string #19

Open
opened 2026-08-18 01:19:24 +00:00 by erik · 0 comments
Owner

When an operator-supplied JS config value fails to parse, js_sandbox::compile_fn does not report the error. It falls back to compiling a function that returns the source text as a string literal. The pipeline then starts, runs, and writes plausible-looking output built from that string. Nothing appears on ERRORS, nothing appears in the logs, and logbus --check reports the config as OK.

Reproduction

An agg stage whose view: is missing one closing brace:

run-rows:
module: agg
config:
filter: () => true
key: (e) => e.run_id
view: |
(events) => {
return { run_id: events[0].run_id, count: events.length };
# ← closing } omitted

Expected: config check fails, naming the stage and the syntax error.

Actual: logbus --check prints config ok. The pipeline runs to completion. The view returns its own source, which the downstream stage spreads character by character, so the output file contains:

[{ "0": "(", "1": "e", "2": "v", "3": "e", "4": "n", "5": "t", "6": "s", "7": ")", ... }]

Root cause

src/util/js_sandbox.rs:252:

pub fn compile_fn(ctx: &rquickjs::Ctx, name: &str, src: &str) -> anyhow::Result<()> {
let as_fn = format!("var {name} = ({src}); if (typeof {name} !== 'function') throw 0; ...");
if ctx.eval::<(), _>(as_fn.as_bytes()).is_ok() {
return Ok(());
}
// Any parse failure lands here, indistinguishable from "operator meant a literal"
let escaped = serde_json::to_string(src).unwrap();
let as_str = format!("var {name} = function() {{ return {escaped}; }};");
ctx.eval::<(), _>(as_str.as_bytes())
.map_err(|e| anyhow::anyhow!("JS compile error in '{name}': {e}"))
}

The fallback is deliberate and load-bearing-valued config fields stay strings (to:ops@example.com, a literal SQL query:). The defect is that it collapses two very different intents into one
silent path: "this was always meant to be meant to be a function and the operatormade a typo."

Blast radius

compile_fn backs ~20 config fields across 9 plugins:

│ Function is the only sensible value │ Literal is legitimate │
│ js (function, path), agg (filter, key, view, start, stop), meta (function), fail2ban (failed, ban), advisor (process, severity) │ sink/email (to, from, subject), sql (query), sink/redis (key), misc/redis_lookup │

Every field in the left column can currently be silently replaced by its own source text. js with path: is the sharpest case — a whole file whose onction, where the string fallback is never a legitimate reading.

Proposed fix

compile_expr already has the heuristic this needs, three lines away (js_sandbox.rs:278):

let is_fn = src.contains("=>") || src.trim_start().starts_with("function");

Apply the same test in compile_fn: if the source looks like a function but fails to parse, propagate the
syntax error instead of falling back. Lit(ops@example.com contains no =>), and every realistic typo in a function-valued field starts failing loudly. This fixes the whole class without
classifying fields one by one.

Optionally, and more precisely: give comp variant and use it for the left columnabove, so key: somestring in an agg is rejected too.

Acceptance

  • A syntax error in any function-valued field fails plugin construction with the stage name, the field name,
    and the QuickJS error text.
  • logbus --check exits non-zero on the repro config above.
  • Literal-valued fields (email to:, sql q strings — covered by a regression test.

Related paper cut, same failure surface

js with path: wraps the file's contents imicolon (})();) is a syntax error while})() is fine. Correct today, but it is an easy mistake that currently lands in exactly this silent path, and it is not documented near the path: optio

When an operator-supplied JS config value fails to parse, js_sandbox::compile_fn does not report the error. It falls back to compiling a function that returns the source text as a string literal. The pipeline then starts, runs, and writes plausible-looking output built from that string. Nothing appears on ERRORS, nothing appears in the logs, and logbus --check reports the config as OK. Reproduction An agg stage whose view: is missing one closing brace: run-rows: module: agg config: filter: () => true key: (e) => e.run_id view: | (events) => { return { run_id: events[0].run_id, count: events.length }; # ← closing } omitted Expected: config check fails, naming the stage and the syntax error. Actual: logbus --check prints config ok. The pipeline runs to completion. The view returns its own source, which the downstream stage spreads character by character, so the output file contains: [{ "0": "(", "1": "e", "2": "v", "3": "e", "4": "n", "5": "t", "6": "s", "7": ")", ... }] Root cause src/util/js_sandbox.rs:252: pub fn compile_fn(ctx: &rquickjs::Ctx, name: &str, src: &str) -> anyhow::Result<()> { let as_fn = format!("var {name} = ({src}); if (typeof {name} !== 'function') throw 0; ..."); if ctx.eval::<(), _>(as_fn.as_bytes()).is_ok() { return Ok(()); } // Any parse failure lands here, indistinguishable from "operator meant a literal" let escaped = serde_json::to_string(src).unwrap(); let as_str = format!("var {name} = function() {{ return {escaped}; }};"); ctx.eval::<(), _>(as_str.as_bytes()) .map_err(|e| anyhow::anyhow!("JS compile error in '{name}': {e}")) } The fallback is deliberate and load-bearing-valued config fields stay strings (to:ops@example.com, a literal SQL query:). The defect is that it collapses two very different intents into one silent path: "this was always meant to be meant to be a function and the operatormade a typo." **Blast radius** compile_fn backs ~20 config fields across 9 plugins: │ Function is the only sensible value │ Literal is legitimate │ |-----------------------------------------------|-------------------------------| │ js (function, path), agg (filter, key, view, start, stop), meta (function), fail2ban (failed, ban), advisor (process, severity) │ sink/email (to, from, subject), sql (query), sink/redis (key), misc/redis_lookup │ Every field in the left column can currently be silently replaced by its own source text. js with path: is the sharpest case — a whole file whose onction, where the string fallback is never a legitimate reading. Proposed fix compile_expr already has the heuristic this needs, three lines away (js_sandbox.rs:278): let is_fn = src.contains("=>") || src.trim_start().starts_with("function"); Apply the same test in compile_fn: if the source looks like a function but fails to parse, propagate the syntax error instead of falling back. Lit(ops@example.com contains no =>), and every realistic typo in a function-valued field starts failing loudly. This fixes the whole class without classifying fields one by one. Optionally, and more precisely: give comp variant and use it for the left columnabove, so key: somestring in an agg is rejected too. Acceptance - A syntax error in any function-valued field fails plugin construction with the stage name, the field name, and the QuickJS error text. - logbus --check exits non-zero on the repro config above. - Literal-valued fields (email to:, sql q strings — covered by a regression test. Related paper cut, same failure surface js with path: wraps the file's contents imicolon (})();) is a syntax error while})() is fine. Correct today, but it is an easy mistake that currently lands in exactly this silent path, and it is not documented near the path: optio
erik self-assigned this 2026-08-18 01:19:24 +00:00
Sign in to join this conversation.
No description provided.