From 6522360672f07e667cb5a583eb12fbdaaedc2fbc Mon Sep 17 00:00:00 2001 From: Alok Subbarao <96636525+asubbarao@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:55:01 -0700 Subject: [PATCH 1/2] feat(routes): exec routes run the request's SQL directly (AS $sql) A route whose handler is exactly one parameter, e.g. CREATE ROUTE sql POST '/sql' AS $sql; is an exec route. CREATE skips handler validation for it. At request time the parameter binds through the normal path (PARAM constraints such as MIN_LENGTH still apply), then its text is split with DuckDB's parser and every statement runs once, in order, on the request connection: DDL, DML, PRAGMA, SET, INSTALL/LOAD, COPY. The LAST statement's result is the response through the existing format negotiation (JSON, CSV, NDJSON, Arrow, route FORMAT, html/text columns); a final no-column statement answers []. The first failing statement stops the batch and answers 422 with the FastAPI-shaped detail on the parameter's loc. REQUIRE runs before any request SQL, query_timeout_ms bounds the batch (504), and a bound row-access/masking policy makes the route fail closed (403). This lets a deployment serve /sql without looping back through quack_query (a second connection per request). Co-Authored-By: Claude Opus 5.5 --- README.md | 36 +++- src/include/quackapi_util.hpp | 7 + src/quackapi_ddl.cpp | 9 +- src/quackapi_openapi.cpp | 12 +- src/quackapi_server.cpp | 96 ++++++++- src/quackapi_util.cpp | 24 +++ test/conformance/cases.jsonl | 7 + test/conformance/routes.sql | 5 + test/sql/quackapi_exec_route.test | 339 ++++++++++++++++++++++++++++++ 9 files changed, 521 insertions(+), 14 deletions(-) create mode 100644 test/sql/quackapi_exec_route.test diff --git a/README.md b/README.md index e3fbc03..b425d81 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ CREATE [OR REPLACE] ROUTE '' [BODY TYPE ''] [PARAM [] [HEADER|COOKIE|QUERY [wire-name]] [DEFAULT ] [GE|GT|LE|LT ] [MIN_LENGTH|MAX_LENGTH ] …] - AS | AS $; DROP ROUTE ; ``` @@ -81,6 +81,7 @@ DROP ROUTE ; | **Method** | `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD` | | **Path** | Must start with `/` (unless joined via `GROUP`). `:param` and `{param}` bind to `$param` | | **Handler** | Any SQL statement that returns a result (typically `SELECT` / `INSERT … RETURNING` / `COPY`) | +| **Exec handler** | Exactly one parameter, `AS $sql`: the request's bound text is executed as SQL — see [Exec routes](#exec-routes-as-sql) | | **Params** | Cast to the types the prepared handler expects; failures → **422** with FastAPI-shaped `detail` | | **Success status** | Default `200`; override with `STATUS 201` (etc.) | | **Auth** | `REQUIRE ` runs a `CREATE AUTH` scheme before the handler | @@ -91,6 +92,39 @@ Handler SQL is validated at `CREATE` time (broken SQL fails at create, not on first request). `CREATE OR REPLACE` does not leave a half-applied route on failure. +### Exec routes (`AS $sql`) + +A handler that is exactly one parameter runs the request's SQL text itself — +an HTTP door to the database, without a loopback through another client: + +```sql +CREATE AUTH ops AS API_KEY; +CREATE ROUTE sql POST '/sql' REQUIRE ops + PARAM sql VARCHAR MIN_LENGTH 1 + AS $sql; +``` + +```sh +curl -X POST http://127.0.0.1:8000/sql -H 'X-API-Key: …' \ + -H 'Content-Type: application/json' \ + -d '{"sql":"CREATE TABLE t AS SELECT 1 AS id; INSERT INTO t VALUES (2); SELECT * FROM t"}' +# [{"id":1},{"id":2}] +``` + +| Behavior | Detail | +|----------|--------| +| **Recognised** | The handler, trimmed, is one `$name` and nothing else. `CREATE` skips handler validation for it; `$claims_*` never qualifies | +| **Binding** | The parameter binds like any other (JSON body field, form field, query string, `HEADER`/`COOKIE`), so `PARAM … MIN_LENGTH`/`DEFAULT` apply | +| **Execution** | The text is split into statements with DuckDB's parser and each runs once, in order, on the request's connection — DDL, DML, `PRAGMA`, `SET`, `INSTALL`/`LOAD`, `COPY`, `ATTACH` | +| **Response** | The **last** statement's result, through the usual negotiation (JSON default, `Accept: text/csv`, `application/x-ndjson`, `application/vnd.apache.arrow.stream`, route `FORMAT`; `html`/`text` columns). A last statement with no columns (`SET`, `PRAGMA …`) answers `[]` | +| **Errors** | The first failing statement stops the batch — later statements never run — and answers **422** with a FastAPI-shaped `detail` whose `loc` is the parameter (`["body","sql"]`) and whose `msg` is DuckDB's error (`Statement 2 of 3 failed: …` for a batch). Earlier statements are not rolled back unless the batch opened its own `BEGIN` | +| **Limits** | `REQUIRE` runs before any request SQL; `query_timeout_ms` bounds the whole batch (504); `max_response_bytes` applies to the last result | +| **Session** | Each request gets its own connection: `SET`, `TEMP` objects and open transactions end with the request. `SET GLOBAL`, tables and `ATTACH` persist | +| **Policies** | Request SQL cannot be policy-rewritten, so while any row-access or masking policy is bound an exec route answers 403 | + +An exec route executes arbitrary SQL with the server's privileges. Keep it on +loopback or behind `REQUIRE`. + ### Scheduled routes quackapi stores `SCHEDULE` metadata but does not run a scheduler. Timing stays diff --git a/src/include/quackapi_util.hpp b/src/include/quackapi_util.hpp index 9855658..5ac0e1e 100644 --- a/src/include/quackapi_util.hpp +++ b/src/include/quackapi_util.hpp @@ -18,6 +18,13 @@ string QuackapiTrim(const string &input); //! JSON string escape including control bytes as \u00XX (auth + OpenAPI + responses). string QuackapiJsonEscape(const string &input); +//! An exec route's handler is exactly one named parameter (`AS $sql`). The +//! request binds that parameter and quackapi executes its text as SQL on the +//! request connection instead of preparing the handler. Returns the parameter +//! name. $claims_* names are server-bound, never request SQL, so they do not +//! qualify. +bool QuackapiExecRouteParam(const string &handler_sql, string ¶m_name); + //===--------------------------------------------------------------------===// // DDL apply-table-function shell (CREATE/DROP ROUTE|GROUP|AUTH|… plan→exec) // diff --git a/src/quackapi_ddl.cpp b/src/quackapi_ddl.cpp index ee89bcc..34156ca 100644 --- a/src/quackapi_ddl.cpp +++ b/src/quackapi_ddl.cpp @@ -1256,8 +1256,13 @@ void ApplyRouteExec(ClientContext &context, TableFunctionInput &data_p, DataChun throw InvalidInputException("Invalid BODY SCHEMA JSON for route \"%s\"", bind_data.route.name); } } - auto prepared = con.Prepare(bind_data.route.handler_sql); - if (prepared->HasError()) { + // An exec route (`AS $sql`) has no SQL of its own to validate: the + // request supplies it, and each request reports its own errors. + string exec_param; + auto prepared = QuackapiExecRouteParam(bind_data.route.handler_sql, exec_param) + ? unique_ptr() + : con.Prepare(bind_data.route.handler_sql); + if (prepared && prepared->HasError()) { // Native libpq path (pg_dsn): handler SQL is Postgres dialect and may // not prepare under DuckDB (domains, DO, INSERT…RETURNING, …). Allow // CREATE when pg_dsn is configured; request time uses libpq, else 500. diff --git a/src/quackapi_openapi.cpp b/src/quackapi_openapi.cpp index a2b3fcc..34ec720 100644 --- a/src/quackapi_openapi.cpp +++ b/src/quackapi_openapi.cpp @@ -207,10 +207,16 @@ string BuildOpenApiDocument(DatabaseInstance &db, const string &server_url) { case_insensitive_map_t expected_types; vector named_params; - auto prepared = con.Prepare(route.handler_sql); + // An exec route (`AS $sql`) documents its one parameter; its response + // columns depend on the request, so it advertises none. + string exec_param; + const bool exec_route = QuackapiExecRouteParam(route.handler_sql, exec_param); + auto prepared = con.Prepare(exec_route ? "SELECT $" + exec_param + "::VARCHAR" : route.handler_sql); if (!prepared->HasError()) { - col_names = prepared->GetNames(); - col_types = prepared->GetTypes(); + if (!exec_route) { + col_names = prepared->GetNames(); + col_types = prepared->GetTypes(); + } expected_types = prepared->GetExpectedParameterTypes(); for (auto &entry : prepared->named_param_map) { named_params.push_back(entry.first); diff --git a/src/quackapi_server.cpp b/src/quackapi_server.cpp index 386c26c..4e41138 100644 --- a/src/quackapi_server.cpp +++ b/src/quackapi_server.cpp @@ -993,6 +993,38 @@ bool ValidateRequestSql(Connection &con, const string &sql, string &error) { } } +//! Execute request SQL text for an exec route (`AS $sql`): every statement in +//! order on the request connection, stopping at the first failure. The last +//! statement's result is the response. Unlike Connection::Query(string), which +//! answers with the first statement that produces rows, a batch such as +//! `CREATE …; INSERT …; SELECT …` answers with its final SELECT. +bool ExecuteRequestSql(Connection &con, const string &sql, unique_ptr &result, string &error) { + vector> statements; + try { + statements = con.ExtractStatements(sql); + } catch (std::exception &ex) { + error = ErrorData(ex).Message(); + return false; + } + if (statements.empty()) { + error = "No statement to execute"; + return false; + } + const auto statement_count = statements.size(); + for (idx_t i = 0; i < statement_count; i++) { + unique_ptr statement_result = con.Query(std::move(statements[i])); + if (statement_result->HasError()) { + error = statement_result->GetError(); + if (statement_count > 1) { + error = StringUtil::Format("Statement %llu of %llu failed: %s", i + 1, statement_count, error); + } + return false; + } + result = std::move(statement_result); + } + return true; +} + //! Resolve the native DuckDB type represented by a BODY TYPE declaration. //! This lets route SQL use $body.items directly: the server inserts the cast //! from the one declaration before DuckDB prepares the handler. @@ -3682,6 +3714,11 @@ void QuackapiHttpServer::HandleRequest(const duckdb_httplib::Request &req, duckd string policy_error; string handler_sql = RewriteHandlerWithPolicies(*db, match.route.handler_sql, authenticated, deny_unauth, policy_error); + // Exec route (`AS $sql`): the bound parameter is the SQL to run. With row + // access or masking policies bound, the handler cannot be policy-rewritten, + // so the check above already fails closed (403) before any request SQL runs. + string exec_param; + const bool exec_route = QuackapiExecRouteParam(match.route.handler_sql, exec_param); if (deny_unauth || !policy_error.empty()) { SetJson(res, 403, "{\"detail\":\"Policy denies unauthenticated access\"}"); finish(); @@ -3953,7 +3990,7 @@ void QuackapiHttpServer::HandleRequest(const duckdb_httplib::Request &req, duckd // Native Postgres path (optional): same model as FastAPI+psycopg — // thread-local libpq, fresh PQexecParams per request. BEFORE DuckDB // prepare so INSERT…RETURNING and other ATTACH-hostile SQL still work. - if (!options.pg_dsn.empty()) { + if (!options.pg_dsn.empty() && !exec_route) { string pg_body, pg_err; auto pg_result = QuackapiTryPgNative(options.pg_dsn, handler_sql, provided, options.max_response_bytes, pg_body, pg_err); @@ -3979,7 +4016,7 @@ void QuackapiHttpServer::HandleRequest(const duckdb_httplib::Request &req, duckd // DuckDB prepare (after libpq short-circuit). Fresh prepare every request: // no SQL-keyed body/plan cache — mutable tables and volatile functions // (now/uuid) must re-execute. - if (has_body_named && !match.route.body_type.empty()) { + if (has_body_named && !match.route.body_type.empty() && !exec_route) { string body_sql_type = has_validated_typed_body ? validated_typed_body.type().ToString() : string(); if (body_sql_type.empty()) { string type_error; @@ -3992,7 +4029,10 @@ void QuackapiHttpServer::HandleRequest(const duckdb_httplib::Request &req, duckd handler_sql = RewriteTypedBodyParameter(handler_sql, body_sql_type); } auto prepare_started = std::chrono::steady_clock::now(); - auto prepared_owned = con.Prepare(handler_sql); + // An exec route prepares a stand-in that names its one parameter, so the + // request's SQL text goes through the same binding and PARAM validation + // (MIN_LENGTH, DEFAULT, HEADER, …) as any other route parameter. + auto prepared_owned = con.Prepare(exec_route ? "SELECT $" + exec_param : handler_sql); sql_prepare_ms = std::chrono::duration(std::chrono::steady_clock::now() - prepare_started).count(); if (prepared_owned->HasError()) { @@ -4061,6 +4101,11 @@ void QuackapiHttpServer::HandleRequest(const duckdb_httplib::Request &req, duckd expected = LogicalType::VARCHAR; } } + const bool is_exec_sql = exec_route && StringUtil::CIEquals(param_name, exec_param); + if (is_exec_sql) { + // The exec parameter is SQL text, whatever its declaration says. + expected = LogicalType::VARCHAR; + } auto it = provided.find(param_name); // Default loc for missing errors: path / header / cookie / query. @@ -4126,7 +4171,8 @@ void QuackapiHttpServer::HandleRequest(const duckdb_httplib::Request &req, duckd {ValidationLocJson(loc_kind, param_name), "Input should be a valid integer", "type_error"}); continue; } - } else if (expected.id() == LogicalTypeId::UNKNOWN || expected.id() == LogicalTypeId::VARCHAR) { + } else if (!is_exec_sql && + (expected.id() == LogicalTypeId::UNKNOWN || expected.id() == LogicalTypeId::VARCHAR)) { // If raw is not a strict integer but DuckDB would accept it as // INTEGER (float/scientific/hex-ish), reject so execute-time // `$param::INTEGER` cannot round. Plain non-numeric strings @@ -4233,7 +4279,7 @@ void QuackapiHttpServer::HandleRequest(const duckdb_httplib::Request &req, duckd // The SQL route delegates the body value to a dynamic SQL function. Validate // it locally first so remote parser/binder failures cannot become 200 [] // when the delegate reports an empty result instead of an error. - if (match.route.pattern == "/sql") { + if (match.route.pattern == "/sql" && !exec_route) { auto sql_it = provided.find("sql"); if (sql_it != provided.end()) { string sql_error; @@ -4246,9 +4292,36 @@ void QuackapiHttpServer::HandleRequest(const duckdb_httplib::Request &req, duckd } auto execute_started = std::chrono::steady_clock::now(); - auto result = prepared->Execute(named_values, false); - sql_execute_ms = - std::chrono::duration(std::chrono::steady_clock::now() - execute_started).count(); + unique_ptr result; + if (exec_route) { + // The request's SQL is client input: any statement failure is a 422 + // on the parameter that carried it, and later statements never run. + auto sql_it = bound_raw.find(exec_param); + if (sql_it == bound_raw.end()) { + SetJson(res, 422, + ValidationErrorJson(IsBodyMethod(req.method) ? "body" : "query", exec_param, "Field required", + "missing")); + finish(); + return; + } + string exec_error; + const bool exec_ok = ExecuteRequestSql(con, sql_it->second.second, result, exec_error); + sql_execute_ms = + std::chrono::duration(std::chrono::steady_clock::now() - execute_started).count(); + if (!exec_ok) { + if (deadline.Expired()) { + SetJson(res, 504, "{\"detail\":\"Query execution deadline exceeded\"}"); + } else { + SetJson(res, 422, ValidationErrorJson(sql_it->second.first, exec_param, exec_error, "value_error")); + } + finish(); + return; + } + } else { + result = prepared->Execute(named_values, false); + sql_execute_ms = + std::chrono::duration(std::chrono::steady_clock::now() - execute_started).count(); + } if (result->HasError()) { // Preserve the status mapping while returning DuckDB's diagnostic so clients can fix bad input. // - Conversion errors → 422 with recovered param name (not "_") @@ -4403,6 +4476,13 @@ void QuackapiHttpServer::HandleRequest(const duckdb_httplib::Request &req, duckd } // No data columns: empty body (redirect / cookie-only responses). + if (data_cols.empty() && exec_route && location_col == names.size() && set_cookie_col == names.size()) { + // A final SET / PRAGMA / BEGIN returns no columns at all; answer the + // same empty JSON array a zero-row SELECT gets, not an empty body. + SetJson(res, match.route.status, "[]"); + finish(); + return; + } if (data_cols.empty()) { res.status = match.route.status; res.body.clear(); diff --git a/src/quackapi_util.cpp b/src/quackapi_util.cpp index 0290984..c0bee0d 100644 --- a/src/quackapi_util.cpp +++ b/src/quackapi_util.cpp @@ -19,6 +19,30 @@ string QuackapiTrim(const string &input) { return input.substr(begin, end - begin); } +bool QuackapiExecRouteParam(const string &handler_sql, string ¶m_name) { + auto handler = QuackapiTrim(handler_sql); + if (handler.size() < 2 || handler[0] != '$') { + return false; + } + auto is_start = [](char c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_'; + }; + if (!is_start(handler[1])) { + return false; + } + for (idx_t i = 2; i < handler.size(); i++) { + if (!is_start(handler[i]) && !(handler[i] >= '0' && handler[i] <= '9')) { + return false; + } + } + auto name = handler.substr(1); + if (StringUtil::StartsWith(StringUtil::Lower(name), "claims_")) { + return false; + } + param_name = name; + return true; +} + string QuackapiJsonEscape(const string &input) { string result; result.reserve(input.size() + 2); diff --git a/test/conformance/cases.jsonl b/test/conformance/cases.jsonl index 97dc95f..af6f3ff 100644 --- a/test/conformance/cases.jsonl +++ b/test/conformance/cases.jsonl @@ -83,3 +83,10 @@ {"id": "post_users_age_float_str", "group": "validation", "method": "POST", "path": "/users?name=x&age=1.5", "headers": {"Content-Type": "application/json"}, "body": "{}", "fastapi_doc": "https://fastapi.tiangolo.com/tutorial/body/", "expect_status": 422, "notes": "Float-string for int should 422"} {"id": "get_user_head_explicit", "group": "methods", "method": "HEAD", "path": "/users/1", "headers": {}, "body": null, "fastapi_doc": "https://fastapi.tiangolo.com/tutorial/path-params/", "expect_status": 200, "notes": "Auto-HEAD for GET /users/:id not registered", "class_hint": "NOT-BUILT-YET"} {"id": "search_repeated_param", "group": "params", "method": "GET", "path": "/search?q=a&q=b", "headers": {}, "body": null, "fastapi_doc": "https://fastapi.tiangolo.com/tutorial/query-params/", "expect_status": 200, "notes": "Repeated query: FastAPI takes first; httplib first-wins in CollectHeaders; params may last-win"} +{"id":"exec_sql_json","group":"exec","method":"POST","path":"/exec-sql","headers":{"Content-Type":"application/json"},"body":"{\"sql\":\"SELECT 42 AS answer\"}","expect_status":200,"expect_ct_substr":"application/json","expect_json_path_eq":{"0.answer":42},"notes":"exec route: request SQL runs directly"} +{"id":"exec_sql_last_statement","group":"exec","method":"POST","path":"/exec-sql","headers":{"Content-Type":"application/json"},"body":"{\"sql\":\"CREATE OR REPLACE TABLE exec_conf AS SELECT 1 AS id; INSERT INTO exec_conf VALUES (2); SELECT list(id ORDER BY id) AS ids FROM exec_conf\"}","expect_status":200,"expect_json_len":1,"expect_json_path_eq":{"0.ids":[1,2]},"notes":"DDL, DML, SELECT in one request; the last statement answers"} +{"id":"exec_sql_csv","group":"exec","method":"POST","path":"/exec-sql","headers":{"Content-Type":"application/json","Accept":"text/csv"},"body":"{\"sql\":\"SELECT 1 AS a, 2 AS b\"}","expect_status":200,"expect_ct_substr":"text/csv","expect_body_contains":["a,b","1,2"]} +{"id":"exec_sql_arrow","group":"exec","method":"POST","path":"/exec-sql","headers":{"Content-Type":"application/json","Accept":"application/vnd.apache.arrow.stream"},"body":"{\"sql\":\"SELECT 1 AS a; SELECT 2 AS b\"}","expect_status":200,"expect_ct_substr":"application/vnd.apache.arrow.stream","notes":"Arrow IPC stream via the community nanoarrow extension"} +{"id":"exec_sql_error_422","group":"exec","method":"POST","path":"/exec-sql","headers":{"Content-Type":"application/json"},"body":"{\"sql\":\"SELECT 1; SELECT nope FROM nowhere\"}","expect_status":422,"expect_body_contains":["Statement 2 of 2 failed","nowhere"],"expect_422":{"loc0":"body","loc1":"sql","type":"value_error"}} +{"id":"exec_sql_auth_401","group":"exec","method":"POST","path":"/exec-sql-secure","headers":{"Content-Type":"application/json"},"body":"{\"sql\":\"SELECT 1 AS x\"}","expect_status":401} +{"id":"exec_sql_auth_ok","group":"exec","method":"POST","path":"/exec-sql-secure","headers":{"Content-Type":"application/json","X-API-Key":"k-secret"},"body":"{\"sql\":\"SELECT 1 AS x\"}","expect_status":200,"expect_json_path_eq":{"0.x":1}} diff --git a/test/conformance/routes.sql b/test/conformance/routes.sql index 9722a94..3b2f7d5 100644 --- a/test/conformance/routes.sql +++ b/test/conformance/routes.sql @@ -128,3 +128,8 @@ SELECT $name::VARCHAR AS name, $age::INTEGER AS age; CREATE ROUTE upload POST '/upload' AS SELECT $file::VARCHAR AS content, $filename::VARCHAR AS filename; + +-- Exec routes: the handler is one parameter; the request's SQL runs directly. +CREATE ROUTE exec_sql POST '/exec-sql' AS $sql; + +CREATE ROUTE exec_sql_secure POST '/exec-sql-secure' REQUIRE site AS $sql; diff --git a/test/sql/quackapi_exec_route.test b/test/sql/quackapi_exec_route.test new file mode 100644 index 0000000..ed885d5 --- /dev/null +++ b/test/sql/quackapi_exec_route.test @@ -0,0 +1,339 @@ +# name: test/sql/quackapi_exec_route.test +# description: An exec route (AS $sql) runs the request's SQL text directly: every statement in order, the last one answers +# group: [sql] + +require quackapi + +require json + +# The handler is exactly one parameter: no CREATE-time prepare, nothing to validate. +query I +CREATE OR REPLACE ROUTE sql POST '/sql' AS $sql; +---- +Route sql: POST /sql + +query II +SELECT name, handler FROM quackapi_routes() WHERE name = 'sql'; +---- +sql $sql + +# Anything other than one bare parameter is still an ordinary, validated handler. +statement error +CREATE OR REPLACE ROUTE not_exec POST '/not_exec' AS $sql $other; +---- +Invalid handler SQL + +# $claims_* is server-bound, never request SQL. +statement error +CREATE OR REPLACE ROUTE claims_exec POST '/claims_exec' AS $claims_sub; +---- +Invalid handler SQL + +# --------------------------------------------------------------------------- +# Single SELECT +# --------------------------------------------------------------------------- +query III +SELECT status, content_type, decode(body) +FROM quackapi_request('POST', '/sql', '{"sql":"SELECT 42 AS answer"}'); +---- +200 application/json [{"answer":42}] + +# A zero-row SELECT is an empty array. +query II +SELECT status, decode(body) +FROM quackapi_request('POST', '/sql', '{"sql":"SELECT 1 AS x WHERE false"}'); +---- +200 [] + +# --------------------------------------------------------------------------- +# Several statements: all run, in order; the LAST statement answers +# --------------------------------------------------------------------------- +query II +SELECT status, decode(body) +FROM quackapi_request('POST', '/sql', '{"sql":"SELECT 1 AS first; SELECT 2 AS second; SELECT 3 AS third"}'); +---- +200 [{"third":3}] + +# DDL, then DML, then SELECT on the same request connection. +query II +SELECT status, decode(body) +FROM quackapi_request('POST', '/sql', + '{"sql":"CREATE TABLE exec_t (id INTEGER, name VARCHAR); INSERT INTO exec_t VALUES (1, ''a''), (2, ''b''); SELECT id, name FROM exec_t ORDER BY id"}'); +---- +200 [{"id":1,"name":"a"},{"id":2,"name":"b"}] + +# The DDL is real: it is visible to the database after the request. +query I +SELECT list(id ORDER BY id) FROM exec_t; +---- +[1, 2] + +# Connection-local state (a TEMP table) is shared by the statements of one request. +query II +SELECT status, decode(body) +FROM quackapi_request('POST', '/sql', + '{"sql":"CREATE TEMP TABLE exec_tmp AS SELECT 7 AS v; SELECT v FROM exec_tmp"}'); +---- +200 [{"v":7}] + +# ... and is gone with the request's connection. +statement error +SELECT v FROM exec_tmp; +---- +exec_tmp + +# A trailing SET (no result columns) answers with an empty array, not an empty body. +query II +SELECT status, decode(body) +FROM quackapi_request('POST', '/sql', '{"sql":"SELECT 1 AS x; SET search_path = ''main''"}'); +---- +200 [] + +# --------------------------------------------------------------------------- +# Each statement runs exactly once (side-effect counter + DuckDB query log) +# --------------------------------------------------------------------------- +statement ok +CREATE TABLE exec_hits (n INTEGER); + +statement ok +CALL enable_logging('QueryLog'); + +query II +SELECT status, decode(body) +FROM quackapi_request('POST', '/sql', + '{"sql":"INSERT INTO exec_hits VALUES (1); PRAGMA disable_checkpoint_on_shutdown; INSERT INTO exec_hits VALUES (2); SELECT list(n ORDER BY n) AS hits FROM exec_hits"}'); +---- +200 [{"hits":[1,2]}] + +statement ok +CALL disable_logging(); + +# The PRAGMA reached DuckDB's query log exactly once. +query I +SELECT list(trim(message)) FROM duckdb_logs +WHERE type = 'QueryLog' AND trim(message) = 'PRAGMA disable_checkpoint_on_shutdown'; +---- +[PRAGMA disable_checkpoint_on_shutdown] + +# --------------------------------------------------------------------------- +# A failing statement: 422 with FastAPI-shaped detail; later statements do not run +# --------------------------------------------------------------------------- +query III +SELECT status, + json_extract_string(decode(body), '$.detail[0].loc'), + json_extract_string(decode(body), '$.detail[0].type') +FROM quackapi_request('POST', '/sql', + '{"sql":"INSERT INTO exec_hits VALUES (3); SELECT nope FROM nowhere; INSERT INTO exec_hits VALUES (4)"}'); +---- +422 ["body","sql"] value_error + +query II +SELECT contains(msg, 'Statement 2 of 3 failed'), contains(msg, 'nowhere') +FROM ( + SELECT json_extract_string(decode(body), '$.detail[0].msg') AS msg + FROM quackapi_request('POST', '/sql', '{"sql":"SELECT 1; SELECT nope FROM nowhere; SELECT 2"}') +); +---- +true true + +# Statement 1 committed before the failure; statement 3 never ran. +query I +SELECT list(n ORDER BY n) FROM exec_hits; +---- +[1, 2, 3] + +# Parser errors are 422 before anything runs. +query II +SELECT status, contains(decode(body), 'SELEC') +FROM quackapi_request('POST', '/sql', '{"sql":"INSERT INTO exec_hits VALUES (9); SELEC 1"}'); +---- +422 true + +query I +SELECT list(n ORDER BY n) FROM exec_hits; +---- +[1, 2, 3] + +# Conversion errors are client errors too. +query II +SELECT status, contains(decode(body), 'Conversion Error') +FROM quackapi_request('POST', '/sql', '{"sql":"SELECT CAST(''nope'' AS INTEGER)"}'); +---- +422 true + +# Empty SQL and a missing sql field are 422. +query II +SELECT status, json_extract_string(decode(body), '$.detail[0].loc') +FROM quackapi_request('POST', '/sql', '{"sql":" ; "}'); +---- +422 ["body","sql"] + +query II +SELECT status, json_extract_string(decode(body), '$.detail[0].type') +FROM quackapi_request('POST', '/sql', '{"other":"SELECT 1"}'); +---- +422 missing + +# --------------------------------------------------------------------------- +# Response format negotiation on the last result: JSON default, CSV / NDJSON via Accept +# (Arrow needs the community nanoarrow extension; covered over real HTTP in +# test/conformance) +# --------------------------------------------------------------------------- +query II +SELECT status, starts_with(content_type, 'text/csv') +FROM quackapi_request('POST', '/sql', '{"sql":"SELECT 0 AS z; SELECT 1 AS a, ''x'' AS b UNION ALL SELECT 2, ''y'' ORDER BY a"}', + headers := MAP {'Accept': 'text/csv'}); +---- +200 true + +query I +SELECT replace(replace(decode(body), E'\r', ''), E'\n', '|') +FROM quackapi_request('POST', '/sql', '{"sql":"SELECT 0 AS z; SELECT 1 AS a, ''x'' AS b UNION ALL SELECT 2, ''y'' ORDER BY a"}', + headers := MAP {'Accept': 'text/csv'}); +---- +a,b|1,x|2,y| + +query II +SELECT status, starts_with(content_type, 'application/x-ndjson') +FROM quackapi_request('POST', '/sql', '{"sql":"SELECT 1 AS a"}', + headers := MAP {'Accept': 'application/x-ndjson'}); +---- +200 true + +# html / text column modes still apply to the last result. +query III +SELECT status, content_type, decode(body) +FROM quackapi_request('POST', '/sql', '{"sql":"SELECT 1; SELECT ''hi'' AS html"}'); +---- +200 text/html; charset=utf-8 hi + +# --------------------------------------------------------------------------- +# PARAM validation still applies to the SQL parameter +# --------------------------------------------------------------------------- +query I +CREATE OR REPLACE ROUTE sql_min POST '/sql_min' PARAM sql VARCHAR MIN_LENGTH 10 AS $sql; +---- +Route sql_min: POST /sql_min + +query II +SELECT status, json_extract_string(decode(body), '$.detail[0].type') +FROM quackapi_request('POST', '/sql_min', '{"sql":"SELECT 1"}'); +---- +422 string_too_short + +query II +SELECT status, decode(body) +FROM quackapi_request('POST', '/sql_min', '{"sql":"SELECT 1 AS long_enough"}'); +---- +200 [{"long_enough":1}] + +# A query-string parameter works too (GET). +query I +CREATE OR REPLACE ROUTE sql_get GET '/sql_get' AS $q; +---- +Route sql_get: GET /sql_get + +query II +SELECT status, decode(body) FROM quackapi_request('GET', '/sql_get?q=SELECT%2042%20AS%20n'); +---- +200 [{"n":42}] + +query II +SELECT status, json_extract_string(decode(body), '$.detail[0].loc') +FROM quackapi_request('GET', '/sql_get?q=SELEC'); +---- +422 ["query","q"] + +# --------------------------------------------------------------------------- +# REQUIRE auth runs before any request SQL +# --------------------------------------------------------------------------- +statement ok +CREATE AUTH exec_key AS API_KEY; + +statement ok +SELECT * FROM quackapi_add_api_key('exec_key', 'exec-secret', 'alice'); + +query I +CREATE OR REPLACE ROUTE sql_auth POST '/sql_auth' REQUIRE exec_key AS $sql; +---- +Route sql_auth: POST /sql_auth + +query II +SELECT status, decode(body) +FROM quackapi_request('POST', '/sql_auth', '{"sql":"INSERT INTO exec_hits VALUES (100); SELECT 1 AS x"}'); +---- +401 {"detail":"Not authenticated"} + +query I +SELECT list(n ORDER BY n) FROM exec_hits; +---- +[1, 2, 3] + +query II +SELECT status, decode(body) +FROM quackapi_request('POST', '/sql_auth', '{"sql":"SELECT 1 AS x"}', + headers := MAP {'X-API-Key': 'exec-secret'}); +---- +200 [{"x":1}] + +# --------------------------------------------------------------------------- +# Row-access policies cannot rewrite arbitrary SQL: an exec route fails closed +# --------------------------------------------------------------------------- +statement ok +CREATE TABLE exec_secret (tenant VARCHAR, v INTEGER); + +statement ok +CREATE ROW ACCESS POLICY exec_tenant AS (tenant VARCHAR) RETURNS BOOLEAN USING (tenant = $claims_sub); + +statement ok +ALTER TABLE exec_secret ADD ROW ACCESS POLICY exec_tenant ON (tenant); + +query I +SELECT status FROM quackapi_request('POST', '/sql', '{"sql":"SELECT v FROM exec_secret"}'); +---- +403 + +statement ok +ALTER TABLE exec_secret DROP ROW ACCESS POLICY exec_tenant; + +query I +SELECT status FROM quackapi_request('POST', '/sql', '{"sql":"SELECT v FROM exec_secret"}'); +---- +200 + +# --------------------------------------------------------------------------- +# query_timeout_ms bounds the whole batch +# --------------------------------------------------------------------------- +statement ok +SET quackapi_query_timeout_ms = 200; + +query I +SELECT status +FROM quackapi_request('POST', '/sql', '{"sql":"SELECT 1; SELECT sum(a.range) FROM range(100000000000) a"}'); +---- +504 + +statement ok +RESET quackapi_query_timeout_ms; + +# --------------------------------------------------------------------------- +# OpenAPI documents the exec parameter +# --------------------------------------------------------------------------- +query I +SELECT json_extract_string(decode(body), '$.paths./sql_min.post.parameters') IS NOT NULL + OR json_extract_string(decode(body), '$.paths./sql_min.post.requestBody') IS NOT NULL +FROM quackapi_request('GET', '/openapi.json'); +---- +true + +statement ok +DROP ROUTE sql; + +statement ok +DROP ROUTE sql_min; + +statement ok +DROP ROUTE sql_get; + +statement ok +DROP ROUTE sql_auth; From 71a046d168a3971d6ef37dba09f8ddb03c230258 Mon Sep 17 00:00:00 2001 From: Alok Subbarao <96636525+asubbarao@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:28:41 -0700 Subject: [PATCH 2/2] server: reject null bytes in exec-route SQL with 422 DuckDB's statement extraction truncates at the first null byte, so a batch like 'SELECT 111 AS firstgarbage' executed the prefix and silently dropped the tail while returning 200. Null bytes are never legitimate in request SQL text; fail fast with a 422 naming the parameter instead. --- src/quackapi_server.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/quackapi_server.cpp b/src/quackapi_server.cpp index 4e41138..fd147e9 100644 --- a/src/quackapi_server.cpp +++ b/src/quackapi_server.cpp @@ -999,6 +999,13 @@ bool ValidateRequestSql(Connection &con, const string &sql, string &error) { //! answers with the first statement that produces rows, a batch such as //! `CREATE …; INSERT …; SELECT …` answers with its final SELECT. bool ExecuteRequestSql(Connection &con, const string &sql, unique_ptr &result, string &error) { + // DuckDB's statement extraction truncates at the first null byte, which + // would silently drop the tail of the batch while executing the prefix. + // Null bytes are never legitimate in request SQL text. + if (sql.find('\0') != string::npos) { + error = "SQL text must not contain null bytes"; + return false; + } vector> statements; try { statements = con.ExtractStatements(sql);