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.
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 infilter/projection/sortare resolved per parent record with JSON-safe escaping. - S3 / SFTP —
${parent.field}tokens in aprefix/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.