Closing the gap: prepared-statement coverage now spans every code path
flAPI v26.05.18 is out. This is a focused follow-up to v26.05.17's security roadmap. After yesterday's release, an internal audit caught that the prepared-statement bind was only wired into the GET executor — POST/PUT/PATCH writes and the Arrow-streaming endpoint still rendered Mustache templates as strings. This release closes that gap and adds the test corpus to prove it.
What changed
Writes now route through the prepared path
executeWrite (which serves POST/PUT/PATCH) now calls the rewriter first, splits the rewritten SQL into statements (quote-aware), distributes the binding plan across statements by counting ? placeholders per statement, and prepares + binds + executes each one. Multi-statement INSERT … ; SELECT … RETURNING templates keep working — each statement is its own prepared statement with the right slice of the binding plan.
Arrow streaming follows the same path
executeQueryRaw (the Arrow-IPC endpoint) now takes the prepared path with the same empty-plan fall-back as the JSON endpoint.
Bind-conversion errors map to HTTP 400
A new flapi::BadRequestError exception class. When a typed parameter can't convert (e.g. id=abc on an int field), the bind layer throws BadRequestError and the request handler returns HTTP 400 with a JSON body — bind-conversion failures are client input errors, not server errors. Prepare/execute failures (genuine server-side problems) still return 500.
Validator hardening
validateDate and validateTime now demand the entire input string be consumed — 2024-03-15' OR 1=1 no longer parses to 2024-03-15 and silently drops the suffix. Same fix as validateInt in v26.05.17.
Coverage matrix
| Path | Defense | Tested |
|---|---|---|
| GET (all 9 validator types) | Prepared bind | ✅ 99 corpus payloads |
| GET with pagination wrap | Prepared bind (inner + count + paginated) | ✅ corpus |
| POST / PUT / PATCH single-statement | Prepared bind | ✅ 16 write-corpus payloads |
| POST multi-statement INSERT … ; SELECT … RETURNING | Per-statement binding slice | ✅ 3 corpus cases |
Arrow streaming (executeQueryRaw) | Prepared bind | ✅ via shared rewriter path |
countSqlPlaceholders helper (quote / dollar-aware) | — | ✅ 6 dedicated unit tests |
Tests
- 586 C++ unit assertions (+6 for
countSqlPlaceholders). - 483 integration tests (+81 from the corpus extensions). Every classic injection pattern (UNION, OR 1=1, comment-evasion, xkcd 327) either returns zero rows or is rejected at the validator/bind boundary — none execute as SQL, regardless of HTTP method.
Why two releases for the same feature
v26.05.17's CHANGELOG claimed "typed scalar params bound via prepared statements" without qualifying that it was GET-only. The honest answer to the "is it really hard-tested?" question — asked after v26.05.17 shipped — was no, not yet. v26.05.18 is what yes looks like: every user-input-bearing code path, end-to-end test coverage, accurate docs.
Full release notes: CHANGELOG.md · Release v26.05.18 · pip install --upgrade flapi-io