fix(qa): bind TEST_PUBKEY as a SQLite parameter instead of interpolating it - #1982
Open
anupamme wants to merge 2 commits into
Open
fix(qa): bind TEST_PUBKEY as a SQLite parameter instead of interpolating it#1982anupamme wants to merge 2 commits into
anupamme wants to merge 2 commits into
Conversation
Move the imperative body of the script into main() and only run it when the file is executed directly, so a test can source the file and exercise individual helpers without running the QA suite against a live target. This is the idiom already used by scripts/staging/disk-monitor.sh:99, which scripts/staging/test-disk-monitor.sh relies on. Behaviour is unchanged. The moved assignments are deliberately not made `local`: bash variables are global unless declared otherwise, so the top-level helpers keep seeing TARGET_CONTAINER, TMP, CURL_TIMEOUT etc. exactly as before, and a trap installed inside main() is still process-wide. Verified movement-only by comparing the sorted, whitespace- stripped line multiset before and after: no original line changed. Groundwork for Kpa-clawbot#1977. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing it §10.2 built its query as SELECT COUNT(*) FROM transmissions WHERE from_node = '$TEST_PUBKEY'; so the SQL layer's safety rested entirely on the outer hex gate rather than on the SQL layer itself. Bind the value instead. The value is hex-encoded and bound as `cast(x'..' as text)` rather than passed to `.parameter set` as a quoted string. Dot-command arguments are split on whitespace, so a payload containing a space makes sqlite3 print the .parameter help to *stdout*, exit 0, and leave the parameter unbound — COUNT(*) then returns 0, which reads exactly like a passing security fix. -bail does not catch it. Hex encoding removes the quoting layer entirely: the value's contribution to the SQL text is drawn from [0-9a-f] only, for arbitrary input rather than only for hex-gated input. Capability is probed, not versioned: bind a known token and read it back, on the operator's binary rather than one we pin. If neither the container nor the host qualifies, fail loudly naming what is needed. There is no interpolating fallback — that would leave the vulnerable path in place under a nicer name. The hex gate is kept as defence in depth, and the exit status and stderr are no longer discarded, so a broken query is distinguishable from a legitimately empty result. Also fixes a double-count in §10.2: the "TARGET_DB_PATH unset" branch incremented $fails and then left count="", so the generic branch incremented it a second time for the same failure. Tests assert both directions — a legitimate pubkey still returns its row (a zero from a command that failed proves nothing), the payload returns 0 while the table holds 2 rows, the old interpolated form leaked all 2, and a missing table exits non-zero with a message on stderr. Refs Kpa-clawbot#1977 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1977. Supersedes #1952. Follow-up filed as #1983.
What §10.2 did
The injection is not reachable today —
TEST_PUBKEYis hex-gated and thescript
exit 2s before the SQL is built. The problem is that the SQL layer'ssafety rests entirely on that outer gate rather than on the SQL layer itself.
#1952 proposed doubling embedded quotes; that is string escaping, not
parameterisation, which is why it was withdrawn in favour of this.
What this does
Per the four points in the sign-off on #1977:
1. Bind the value. A constant
SELECTand a bound:pubkey, fed tosqlite3 on stdin. The SQL no longer crosses the remote shell as a command
word, so there is no
printf %qon the query at all any more.Why hex rather than
.parameter set :pk '<value>'. Dot-command argumentsare split on whitespace, so a payload containing a space produces too many
arguments — and sqlite3 responds by printing the
.parameterhelp tostdout, exiting 0, and leaving
:pkunbound.COUNT(*)thenreturns 0, which reads exactly like a passing security fix.
-baildoes notcatch it. Verified on 3.51.0:
.parameter set :pk 1+1also binds the integer2— the value is evaluatedas an SQL expression and only falls back to a text literal when evaluation
fails. So interpolating into the
.parameter setline trades one hazard foranother.
Hex-encoding removes the quoting layer instead of adding one: the value is
bound as
cast(x'<hex>' as text), so its contribution to the SQL text isdrawn from the alphabet
[0-9a-f]only. Nothing to quote, no tokenizer arityhazard, and it holds for arbitrary input rather than only for hex-gated
input — which is the point.
Verified against a fixture table holding two rows, one of them
deadbeef:deadbeef, bound ascast(x'6465616462656566' as text)1' OR 1=1 --, bound the same way0' OR 1=1 --, interpolated the current way2(whole table)transmissionstable,-bailParse error … no such tableon stderr2. Probe the capability, not a version.
resolve_sqlite_runnerbindscorescope-probe-okand asserts it comes back — a round trip, not a bare.parameter init, so the positive control runs against the operator's actualbinary rather than one we pin. If neither the container nor the host qualifies,
it fails loudly and names what is needed:
There is deliberately no interpolating fallback. That would leave the
vulnerable path in place under a nicer name.
3. The hex gate is kept, with its comment updated to say why: for the SQL
layer it is now defence in depth rather than the only guard. Redundant is not
the same as wrong.
4. The exit status and stderr survive.
-batch -bail -init /dev/null -noheader -list(stop at the first SQL error; ignore the operator's~/.sqliterc, where a stray.modewould make the count unparseable; stdoutis exactly the number). Query stderr is captured and printed on failure rather
than sent to
/dev/null, so a broken query is distinguishable from alegitimately empty result. Probe stderr is collected too, and printed only if
both probes fail — the container miss is the known-normal case, so surfacing
it on every run would be noise.
Also fixed
An existing double-count in §10.2: the
TARGET_DB_PATH unsetbranchincremented
$failsand then leftcount="", so the generic branchincremented it a second time for the same failure.
read_retain_countnowgives §10.2 exactly one increment point. Opportunistic cleanup in a file
already being touched (AGENTS.md line 318).
Tests
New
qa/scripts/test-blacklist-sql.sh, wired into thego-testjob. 24assertions, modelled on
scripts/staging/test-disk-monitor.sh.Both directions are asserted, because a zero from a command that failed proves
nothing:
deadbeefstill returns its row (1, exit 0), and sodoes
cafebabe; an absent pubkey returns0.' OR 1=1 --returns0while the table demonstrably holds2 rows, and the old interpolated form is asserted to leak all
2. That lastassertion is what makes the
0above worth something.transmissionstableexits non-zero with a message on stderr and nothing on stdout.
sql_hex_literaloutput matches^x'[0-9a-f]*'$for the SQLpayloads, a backslash,
$(id)/ backticks, an embedded newline,héllo, anda 4096-byte repetitive string. That last one is a regression guard for
od -v: without the flagodcollapses repeated identical lines to*.run_sqlitewith no resolved runner refuses rather than guessing.Group 2 skips loudly (rather than silently) if
sqlite3is not on PATH; group1 needs no sqlite3 and always runs.
Mutation-tested — each of these breaks the suite, so the assertions have
teeth:
injection payload → 0 rows — expected '0' got '2'.parameter set '%s'expected '0' got '.parameter CMD ...'od -v*, plusexpected '8192' got '33'Commit 1 is a behaviour-neutral refactor that moves the imperative body into
main()behind aBASH_SOURCEguard, so the test can source the script andexercise individual helpers. Same idiom as
scripts/staging/disk-monitor.sh:99.Verification
bash qa/scripts/test-blacklist-sql.sh→ 24 passed, 0 failedbash -non both scriptsssh/docker/sqlite3against a real fixture DB: success(
sqlite3 runner: host, count 2), query failure (classified message +Parse error … no such table,fails=1), and no-capability (the loud blockabove, both probe stderrs,
fails=1— not 2)go-test, which runs whenchanges.outputs.code == 'true';qa/scripts/*.shdoes not match that job's^docs/|[.]md$|^LICENSE$documentation filter, so it is not skippedDeliberately out of scope
docker execbranch is dead on current images → filed as blacklist-test.sh §10.2: the docker exec sqlite3 branch can never succeed — the app image has no sqlite3 #1983. Theapp container has no
sqlite3at all:Dockerfile:15is pure-Go SQLite withno CGO, and the
apk addinstalls onlymosquitto mosquitto-clients supervisor caddy wget. So the host "fallback" is the only path that has everexecuted, silently, because both branches discarded stderr. This change keeps
both branches and merely makes the outcome visible (
sqlite3 runner: …onevery run).
-readonlyon the target DB. Tempting, and verified compatible with.parameter(the binding table lives in the TEMP database), but a WALdatabase needing journal recovery can refuse a read-only open. Adding it here
risks exactly the "trades an unreachable injection for a script that does not
run" outcome flagged in the fix: fix security issue in blacklist-test.sh #1952 thread. Worth its own issue.
2>/dev/nullsites in this file, which also sit awkwardly withqa/README.md's "Don't silence stderr". Only the §10.2 lines named in thesign-off are touched.
🤖 Generated with Claude Code