← Blog Blog · Security

Injection-safe by construction

Parent/child pipelines run a child query once per parent record. The obvious way to wire the parent's values into that query — string substitution — is also the classic way to get a SQL-injection bug. faucet-stream doesn't do it that way.

faucet-stream·August 25, 2026·~6 min read

The shape: one parent, many children

A common movement pattern is a parent/child fan-out (a matrix run): a parent stream yields records — accounts, tenants, report ids — and for each one a child query runs, parameterized by that parent record. One config template, N executions.

# parent stream: one row per account
source: { type: postgres, config: { query: "select id, region from accounts" } }

# child (matrix) query — runs once per parent row.
# ${parent.id} and ${parent.region} are NOT string-substituted:
matrix:
  parent: accounts
  child:
    source:
      type: postgres
      config:
        query: |
          select * from orders
          where account_id = ${parent.id}
            and region = ${parent.region}

The interesting tokens are ${parent.id} and ${parent.region}. They have to become real values in the child query — the question is how.

The trap: string interpolation

The tempting implementation is to paste the parent's value into the SQL string: where account_id = '${parent.id}'. It works in the demo, and it is a SQL-injection hole. A parent value of 1'; drop table orders; -- — whether malicious or just a stray quote in real data — is now executing SQL. Escaping-by-hand is a losing game; the only robust answer is to never build SQL by concatenation.

What faucet does: parameterize, per parent row

faucet-stream treats ${parent.…} tokens as bind parameters, not text. In the Postgres source, each token is rewritten to an additional positional bind marker — appended after the query's static params — and filled with the parent record's value at execution time. The SQL string the database compiles is fixed; only the bound values change per parent row.

The value never enters the query text. So a parent field containing '; drop table … is bound as a literal string argument and compared as data — there is nothing to inject into. It is safe by construction, not because someone remembered to escape it.

One query template therefore fans out into one parameterized execution per parent record — the performance win of a template and the safety of prepared statements, together.

The same discipline across connectors

  • Postgres — ${parent.field} → extra positional bind markers, filled per parent row.
  • MySQL — the same tokens become safe ? bind parameters, never string-interpolated.
  • MongoDB — {key} placeholders in filter/projection/sort are resolved per parent record with JSON-safe escaping.
  • S3 / SFTP — ${parent.field} tokens in a prefix/path are substituted per parent record for object-store fan-out.

And it runs in parallel — with a ceiling

The per-parent children aren't serialized: faucet runs them concurrently under a semaphore whose width is execution.max_concurrent (default: min(cores, 8)). The cap is deliberate — each child is a full pipeline with its own pools, and they usually target one database, so an unbounded fan-out would exhaust connections rather than go faster. Raise the knob when the workload genuinely benefits.

Takeaway

Parameterized-by-default is a small decision with a large blast radius: it turns a whole class of injection bugs into something you can't write. If you fan a parent stream out into child queries with faucet, the safe path is the only path. See the matrix reference in the documentation.

More on the blog, or read the documentation.

Get started

Your first pipeline runs in five minutes.

Install the CLI, scaffold a config, and move real data — nothing external to stand up.

curl -LsSf https://github.com/faucet-hq/faucet-stream/releases/latest/download/faucet-cli-installer.sh | sh
brew install faucet-hq/faucet-stream/faucet-cli