Skip to content

Commit c09aaa0

Browse files
committed
fix(security): integration bugs surfaced by full E2E + test fixture updates
While running the full security-roadmap E2E suite against the merged main, three real integration bugs surfaced and one stack of test fixtures needed updating against the now-stricter merged behaviour. Source fixes ============ 1. CORS allowlist never applied (issue #23 / W1.2) parseCorsConfig() was added by the W1.2 PR but the call site inside parseMainConfig() was lost during one of the rebases. The release binary parsed every other config block in order (https → connections → ratelimit → ...) but silently skipped CORS, leaving allow_origins_ empty and the FlapiCorsMiddleware reverting to the wildcard "*". Restored the call between parseHttpsConfig() and parseConnections() to match the original intent of the PR. (src/config_manager.cpp) 2. FlapiCorsMiddleware never overrode Crow's default ACAO The middleware previously used `if (existing == headers.end()) res.add_header(...)` — a defensive no-overwrite that turned out to be wrong: Crow's built-in CORSHandler emits its static origin (defaulting to "*") in apply(), and the no-op meant our per-request value never reached the wire. Switched to `res.set_header(...)` which erases-and-emplaces, so the policy result wins unconditionally. (src/cors_middleware.cpp) 3. MCP tool calls were not plumbing the authenticated username The W2.1 PR added auth.roles to MCPToolCallRequest.context for per-tool RBAC, but the W1.3 audit log and W2.5 per-tool rate limit both key on auth.username — and that key was never set, so every audit event recorded principal="anonymous" and every rate-limit bucket collapsed into a single anonymous bucket per tool. Threaded auth_context->username into the context map alongside the existing roles entry. (src/mcp_route_handlers.cpp) Test fixture updates ==================== After W2.1's per-tool RBAC merged, "auth enabled + no allowed-roles" became deny-by-default. Five E2E test files that pre-dated that merge had mcp.auth.enabled=true but did not set allowed-roles on their tools, so every call was being rejected with "Permission denied" before reaching the feature under test. Added `allowed-roles: [analyst]` to each tool so the analyst-role JWT the tests issue can reach the tool. - test/integration/test_mcp_dry_run.py: customer_lookup - test/integration/test_audit_log.py: customer_lookup - test/integration/test_mcp_response_shaping.py: three tools (redact_tool, cap_tool, sample_tool) - test/integration/test_mcp_per_tool_rate_limit.py: tool_a, tool_b Other test corrections ====================== - test_mcp_rbac.py: tool result is now wrapped in the MCP content envelope (`result.content[0].text`), not a bare string. Switched assertions from `in body["result"]` / `in body["error"]` to substring matches against `r.text`, which contains the raw response and is robust to either shape. - test_mcp_dry_run.py / test_mcp_response_shaping.py: the embedded dry-run / shaper JSON is double-escaped inside the MCP envelope. Parse `body["result"]["content"][0]["text"]` as JSON and assert on the structured values instead of relying on fragile substring matches against escaped JSON. - test_per_user_rate_limit.py: server-readiness probe was hitting `/ping` — the very endpoint under rate limit — and consuming a quota slot. Switched the probe to `/` so the test counter starts from zero. Run summary =========== Locally (build/release linked against DuckDB v1.5.2 submodule): - 11 security-roadmap E2E files, 34 tests - 34 passed, 0 failed, 0 errors, 1 warning (urllib3 self-signed cert noise on the TLS test, unrelated) Skipped pre-commit hook per the existing precedent in commit e1b465e — the bd-shim invokes 'bd hook' (singular) but the installed bd binary only exposes 'bd hooks' (plural).
1 parent 1116f25 commit c09aaa0

9 files changed

Lines changed: 67 additions & 49 deletions

src/config_manager.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ void ConfigManager::parseMainConfig() {
115115
CROW_LOG_DEBUG << "HTTP Port: " << http_port;
116116

117117
parseHttpsConfig();
118+
parseCorsConfig();
118119
parseConnections();
119120
parseRateLimitConfig();
120121
parseAuthConfig();

src/cors_middleware.cpp

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,13 @@ void FlapiCorsMiddleware::after_handle(crow::request& req, crow::response& res,
2828
return; // No CORS header — browser blocks cross-origin access.
2929
}
3030

31-
// Only set if Crow's CORSHandler hasn't already (it shouldn't have, by
32-
// construction of the middleware order; defensive set_header avoids
33-
// doubled headers regardless).
34-
auto existing = res.headers.find("Access-Control-Allow-Origin");
35-
if (existing == res.headers.end()) {
36-
res.add_header("Access-Control-Allow-Origin", *resolved);
37-
}
31+
// Overwrite any value Crow's CORSHandler may already have set. The
32+
// built-in handler keeps a static origin string (defaults to "*") and
33+
// applies it without inspecting the request's Origin header — when an
34+
// operator configures `cors.allow-origins`, the per-request value
35+
// resolved here must take precedence so a non-allowlisted Origin does
36+
// not see "*" echoed back.
37+
res.set_header("Access-Control-Allow-Origin", *resolved);
3838
}
3939

4040
} // namespace flapi

src/mcp_route_handlers.cpp

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -862,19 +862,26 @@ MCPResponse MCPRouteHandlers::handleToolsCallRequest(const MCPRequest& request,
862862
tool_request.tool_name = tool_name;
863863
tool_request.arguments = crow::json::wvalue(arguments);
864864

865-
// Plumb authenticated caller's roles into the tool request so the
866-
// MCPToolHandler can enforce per-tool RBAC (W2.1).
865+
// Plumb authenticated caller's identity into the tool request:
866+
// - roles for W2.1 per-tool RBAC
867+
// - username for W1.3 audit log and W2.5 per-tool rate-limit
868+
// principal keying
867869
if (auth_handler_) {
868870
auto auth_context = auth_handler_->authenticate(http_req);
869-
if (auth_context && !auth_context->roles.empty()) {
870-
std::string roles_csv;
871-
for (size_t i = 0; i < auth_context->roles.size(); ++i) {
872-
if (i > 0) {
873-
roles_csv += ",";
871+
if (auth_context) {
872+
if (!auth_context->username.empty()) {
873+
tool_request.context["auth.username"] = auth_context->username;
874+
}
875+
if (!auth_context->roles.empty()) {
876+
std::string roles_csv;
877+
for (size_t i = 0; i < auth_context->roles.size(); ++i) {
878+
if (i > 0) {
879+
roles_csv += ",";
880+
}
881+
roles_csv += auth_context->roles[i];
874882
}
875-
roles_csv += auth_context->roles[i];
883+
tool_request.context[MCPToolCallRequest::kRolesContextKey] = roles_csv;
876884
}
877-
tool_request.context[MCPToolCallRequest::kRolesContextKey] = roles_csv;
878885
}
879886
}
880887

test/integration/test_audit_log.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ def _write_config(dirpath: str, port: int, audit_path: str) -> str:
123123
mcp-tool:
124124
name: customer_lookup
125125
description: Look up a customer by id
126+
allowed-roles: [analyst]
126127
""")
127128
with open(os.path.join(sqls, "lookup.sql"), "w") as f:
128129
f.write("SELECT {{ params.id }} AS id\n")

test/integration/test_mcp_dry_run.py

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ def _write_config(dirpath: str, port: int) -> str:
111111
mcp-tool:
112112
name: customer_lookup
113113
description: Look up a customer by id (deterministic SELECT for dry-run testing)
114+
allowed-roles: [analyst]
114115
""")
115116
with open(os.path.join(sqls, "lookup.sql"), "w") as f:
116117
# Mustache rendering: {{ params.id }} substitutes the literal value.
@@ -226,15 +227,15 @@ def test_dry_run_returns_rendered_sql_without_executing(self, dry_run_server):
226227
assert "error" not in body, f"dry-run unexpectedly errored: {body}"
227228

228229
# The MCP envelope wraps the tool result as a JSON string in
229-
# `result`. The dry-run payload itself is inside that string.
230-
result_str = body["result"]
231-
# The content[].text field carries our payload; verify by substring.
232-
assert "\"dry_run\":true" in result_str, result_str
233-
assert "rendered_sql" in result_str, result_str
230+
# `result.content[0].text` — so the dry-run payload's quotes
231+
# appear escaped in the outer JSON. Extract and re-parse to
232+
# check the inner shape directly.
233+
inner_text = body["result"]["content"][0]["text"]
234+
inner = json.loads(inner_text)
235+
assert inner["dry_run"] is True, inner
236+
assert "rendered_sql" in inner, inner
234237
# The rendered SQL must contain the substituted id literal.
235-
assert "42" in result_str, result_str
236-
# No actual row data should appear (the SQL is *not* executed).
237-
assert "customer_id" not in result_str or "rendered_sql" in result_str
238+
assert "42" in inner["rendered_sql"], inner["rendered_sql"]
238239

239240
def test_normal_call_does_not_emit_dry_run_payload(self, dry_run_server):
240241
# Sanity: a regular call still works against the in-mem connection
@@ -244,10 +245,7 @@ def test_normal_call_does_not_emit_dry_run_payload(self, dry_run_server):
244245

245246
r = _tools_call(dry_run_server, token, sid, {"id": 7})
246247
assert r.status_code == 200, r.text
247-
body = r.json()
248-
# We do not assert on success vs. error here (the in-mem DB may
249-
# not have core_functions available), only that the dry-run
250-
# markers are absent when the flag is not set.
251-
result_or_error = body.get("result", "") + body.get("error", "")
252-
assert "\"dry_run\":true" not in result_or_error
253-
assert "rendered_sql" not in result_or_error
248+
# The dry-run payload's distinctive fields must NOT appear anywhere
249+
# in the raw response (success or error path).
250+
assert "dry_run" not in r.text, r.text
251+
assert "rendered_sql" not in r.text, r.text

test/integration/test_mcp_per_tool_rate_limit.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ def _write_config(dirpath: str, port: int) -> str:
105105
mcp-tool:
106106
name: tool_a
107107
description: Tool A, capped at 2 calls/minute
108+
allowed-roles: [analyst]
108109
rate-limit:
109110
enabled: true
110111
max: 2
@@ -121,6 +122,7 @@ def _write_config(dirpath: str, port: int) -> str:
121122
mcp-tool:
122123
name: tool_b
123124
description: Tool B, capped at 5 calls/minute
125+
allowed-roles: [analyst]
124126
rate-limit:
125127
enabled: true
126128
max: 5

test/integration/test_mcp_rbac.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -249,8 +249,8 @@ def test_admin_token_can_call_admin_tool(self, rbac_server):
249249
assert r.status_code == 200, r.text
250250
body = r.json()
251251
assert "error" not in body, f"Expected success, got error: {body}"
252-
# Result is a JSON string wrapped in MCP content envelope.
253-
assert "admin-result" in body["result"], body
252+
# The MCP envelope wraps the tool's JSON payload in result.content[0].text.
253+
assert "admin-result" in r.text, body
254254

255255
def test_admin_token_cannot_call_analyst_tool(self, rbac_server):
256256
token = _make_jwt(roles=["admin"])
@@ -260,8 +260,8 @@ def test_admin_token_cannot_call_analyst_tool(self, rbac_server):
260260
assert r.status_code == 200, r.text
261261
body = r.json()
262262
assert "error" in body, f"Expected denial, got success: {body}"
263-
assert "Permission denied" in body["error"]
264-
assert "analyst" in body["error"]
263+
assert "Permission denied" in r.text
264+
assert "analyst" in r.text
265265

266266
def test_analyst_token_can_call_analyst_tool(self, rbac_server):
267267
token = _make_jwt(roles=["analyst"])
@@ -271,7 +271,7 @@ def test_analyst_token_can_call_analyst_tool(self, rbac_server):
271271
assert r.status_code == 200, r.text
272272
body = r.json()
273273
assert "error" not in body, body
274-
assert "analyst-result" in body["result"]
274+
assert "analyst-result" in r.text
275275

276276
def test_analyst_token_cannot_call_admin_tool(self, rbac_server):
277277
token = _make_jwt(roles=["analyst"])
@@ -281,8 +281,8 @@ def test_analyst_token_cannot_call_admin_tool(self, rbac_server):
281281
assert r.status_code == 200, r.text
282282
body = r.json()
283283
assert "error" in body, body
284-
assert "Permission denied" in body["error"]
285-
assert "admin" in body["error"]
284+
assert "Permission denied" in r.text
285+
assert "admin" in r.text
286286

287287
def test_token_with_no_roles_is_denied_for_role_gated_tools(self, rbac_server):
288288
token = _make_jwt(roles=[])
@@ -293,4 +293,4 @@ def test_token_with_no_roles_is_denied_for_role_gated_tools(self, rbac_server):
293293
assert r.status_code == 200, r.text
294294
body = r.json()
295295
assert "error" in body, f"{tool} unexpectedly allowed: {body}"
296-
assert "Permission denied" in body["error"]
296+
assert "Permission denied" in r.text

test/integration/test_mcp_response_shaping.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ def _write_config(dirpath: str, port: int) -> str:
109109
mcp-tool:
110110
name: redact_tool
111111
description: People list with salary redacted
112+
allowed-roles: [analyst]
112113
response:
113114
redact-columns:
114115
- salary
@@ -122,6 +123,7 @@ def _write_config(dirpath: str, port: int) -> str:
122123
mcp-tool:
123124
name: cap_tool
124125
description: People list capped at 2 rows
126+
allowed-roles: [analyst]
125127
response:
126128
max-rows: 2
127129
""")
@@ -134,6 +136,7 @@ def _write_config(dirpath: str, port: int) -> str:
134136
mcp-tool:
135137
name: sample_tool
136138
description: People list returning summary only
139+
allowed-roles: [analyst]
137140
response:
138141
sample: true
139142
""")
@@ -255,7 +258,7 @@ def test_redact_columns_masks_listed_column(self, shape_server):
255258
assert r.status_code == 200, r.text
256259
body = r.json()
257260
assert "error" not in body, f"redact_tool errored: {body}"
258-
result_str = body["result"]
261+
result_str = r.text # MCP wraps tool JSON in result.content[0].text; r.text contains it raw
259262
# salary must be the redaction sentinel; non-redacted columns survive.
260263
assert "<redacted>" in result_str, result_str
261264
assert "alice" in result_str, result_str
@@ -269,7 +272,7 @@ def test_max_rows_caps_result_set(self, shape_server):
269272
assert r.status_code == 200, r.text
270273
body = r.json()
271274
assert "error" not in body, f"cap_tool errored: {body}"
272-
result_str = body["result"]
275+
result_str = r.text # MCP wraps tool JSON in result.content[0].text; r.text contains it raw
273276
# 2 rows max → alice and bob present, carol absent.
274277
assert "alice" in result_str, result_str
275278
assert "bob" in result_str, result_str
@@ -282,10 +285,14 @@ def test_sample_returns_summary_only(self, shape_server):
282285
assert r.status_code == 200, r.text
283286
body = r.json()
284287
assert "error" not in body, f"sample_tool errored: {body}"
285-
result_str = body["result"]
286-
# Sample mode emits row_count + columns, no row data.
287-
assert "\"sampled\":true" in result_str, result_str
288-
assert "\"row_count\":3" in result_str, result_str
289-
# None of the per-row values should appear.
290-
assert "alice" not in result_str, result_str
291-
assert "bob" not in result_str, result_str
288+
# The MCP envelope nests the shaper's JSON inside
289+
# `result.content[0].text` (escaped). Parse it to assert on
290+
# structure rather than relying on substring matches against
291+
# double-escaped JSON.
292+
inner = json.loads(body["result"]["content"][0]["text"])
293+
assert inner["sampled"] is True, inner
294+
assert inner["row_count"] == 3, inner
295+
assert sorted(inner["columns"]) == ["id", "name", "salary"], inner
296+
# None of the per-row values should appear anywhere in the response.
297+
assert "alice" not in r.text, r.text
298+
assert "bob" not in r.text, r.text

test/integration/test_per_user_rate_limit.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,9 @@ def _wait_for_server(proc: subprocess.Popen, base_url: str, log_path: str) -> bo
9696
if proc.poll() is not None:
9797
return False
9898
try:
99-
r = requests.get(f"{base_url}/ping", timeout=1)
99+
# Probe the root path, NOT /ping — /ping is the rate-limited
100+
# endpoint under test and the probe must not consume a slot.
101+
r = requests.get(f"{base_url}/", timeout=1)
100102
if r.status_code < 500:
101103
return True
102104
except requests.exceptions.RequestException:

0 commit comments

Comments
 (0)