fix(security): RBAC M3 follow-up — namespace traversal + case-insensitive deny + memory audit emit (#3205)#3249
Merged
Merged
Conversation
…tive deny + memory audit emit Three reviewer-flagged bugs in PR #3205 (RBAC M3): #7 — Namespace path traversal (REAL): `UserMemoryAccess::can_read/can_write` deferred to the generic `capability::glob_matches`, where `*` greedily matches any text including path separators. So a pattern of `kv:user_*` matched `kv:user_../admin` or `kv:user_evil/etc/passwd` — letting a memory tool that builds the namespace from user-controlled input cross into another user's bucket. Fix: introduce `namespace_glob_matches` with two stricter rules: 1. Reject any candidate containing a `..` segment (delimited by `/`, `:`, or whitespace) outright. 2. `*` may not span `/` or `:` separators inside a longer pattern (e.g. `kv:user_*` only matches a single component after the prefix). `*` standalone still matches any non-traversing namespace so the owner / admin "see everything" UX is preserved. Kept separate from the generic `glob_matches` (which is used for tool-name and capability matching where collapsing across separators is desirable). #8a — Case-sensitive deny lists (PARTIAL → fixed): `UserToolPolicy`, `ChannelToolPolicy`, and `UserToolCategories` matched tool names case-sensitively, so a deny rule for `shell_exec` would not catch a hallucinated `SHELL_EXEC` invocation. Built-in tools dispatch by exact case so the call would have failed downstream anyway, but MCP / skill tool providers may accept their own case variants and the deny list is supposed to be the authoritative gate. ASCII-lowercased both pattern and tool name at every per-user check site. Limited to per-user policy layers — the rate-limit buckets and capability matchers continue using exact-case comparison since tool identities there are content-addressed. #8b — Memory ACL denial silently dropped from audit chain (REAL): `routes/memory.rs::auth_denied` returned a 403 without recording a `PermissionDenied` row, while the parallel `routes/audit.rs`, `routes/budget.rs`, `routes/authz.rs`, and the global auth middleware all emit one. A privilege probe against `/api/memory*` was therefore invisible to an admin reading `/api/audit`. Fix: extended `auth_denied` to take `&AppState` + `&Extensions` and emit a `PermissionDenied` audit row before returning 403. Anonymous callers are recorded with `user_id = None`; authenticated-but-denied callers carry their attributed `user_id` plus user / role in the detail. Tests added: - `memory_access_namespace_blocks_path_traversal` — `kv:user_../admin`, `kv:user_alice/../bob`, and separator-crossing globs all denied. - `memory_access_star_pattern_still_rejects_traversal` — even `["*"]` rejects `..` candidates. - `user_deny_is_case_insensitive`, `channel_deny_is_case_insensitive`, `categories_deny_is_case_insensitive` — pin lowercase normalisation. - `auth_denied_emits_audit_row_for_anonymous` and `auth_denied_emits_audit_row_for_authenticated_user` — pin the audit-emit contract for both anonymous and attributed denials.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
#3233 added Arc<RwLock<Vec<ApiUserAuth>>> user_api_keys to AppState. The new audit_test_app_state helper landed before that merged so its struct-literal init was missing the field. CI flagged E0063 on macOS and Windows after rebase. Initialize as empty Vec — these tests don't exercise auth at all, they call auth_denied directly with synthesized extensions.
This was referenced Apr 26, 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.
Summary
Three reviewer-flagged bugs from merged PR #3205 (RBAC M3 — per-user tool policy + memory namespace ACL). Each was re-verified against current main before fixing; verdicts and concrete evidence below.
Item #7 — Namespace glob path traversal — REAL
UserMemoryAccess::can_read/can_write(crates/librefang-types/src/user_policy.rs) deferred tocapability::glob_matches, where*greedily matches any text including/and:. So:glob_matches("kv:user_*", "kv:user_../admin")->true(the*swallows../admin)glob_matches("kv:user_*", "kv:user_evil/etc/passwd")->trueglob_matches("kv:*", "kv:user_../admin")->trueA memory tool that constructs the namespace from user input could therefore cross into another user's bucket — even though the per-user pattern was supposed to scope them to
kv:user_<their-name>.Fix. Introduce
namespace_glob_matcheswith two stricter rules:..segment (delimited by/,:, or whitespace) outright.*may not span/or:separators inside a longer pattern —kv:user_*only matches a single component after the prefix.*standalone still matches any non-traversing namespace so the owner/admin "see everything" UX is preserved. Kept separate from the genericglob_matches(which is used for tool-name and capability matching where collapsing across separators is intentional).Item #8a — Case-sensitive deny lists — PARTIAL -> fixed
UserToolPolicy,ChannelToolPolicy, andUserToolCategoriesmatched tool names case-sensitively (crates/librefang-types/src/user_policy.rs:151,111,195):glob_matches("shell_exec", "SHELL_EXEC")->falseBuilt-in tools dispatch by exact-case match (
tool_runner.rs:1041) so aSHELL_EXECinvocation would not have executed; the call would have hit the "Unknown tool" arm. But MCP and skill tool providers may accept their own case variants, and the deny list is supposed to be the authoritative gate regardless of the dispatcher's tolerance. Defense in depth: ASCII-lowercase both pattern and tool name at every per-user check site.Scope kept tight — only the three per-user
check_toolmethods. Rate-limit buckets and capability matchers stay exact-case (tool identities there are content-addressed).Item #8b — Memory ACL denial silently dropped from audit — REAL
routes/memory.rs::auth_denied(the helper called on everyLibreFangError::AuthDeniedfrom the namespace guard) returned a 403 without recording aPermissionDeniedaudit row. Meanwhile every other admin-gated RBAC endpoint emits one:routes/audit.rs::require_admin— emits PermissionDeniedroutes/budget.rs:468,482— emits PermissionDeniedroutes/authz.rs:60,74— emits PermissionDeniedmiddleware.rs:435,765— emits PermissionDeniedSo a privilege probe against
/api/memory*was invisible to admins reading/api/audit.Fix.
auth_deniednow takes&AppState+&Extensionsand writes aPermissionDeniedrow viastate.kernel.audit().record_with_context(...)before returning 403. Anonymous callers ->user_id = None; authenticated-but-denied callers -> attributeduser_idplus user / role in the detail string.Files changed
crates/librefang-types/src/user_policy.rs—namespace_glob_matches+has_path_traversal+ case-insensitivecheck_toolonUserToolPolicy/ChannelToolPolicy/UserToolCategories+ testscrates/librefang-api/src/routes/memory.rs—auth_deniedaudit emit + testsTest plan
memory_access_namespace_blocks_path_traversal—kv:user_../admin,kv:user_alice/../bob, and separator-crossing globs all denied;kv:user_alicestill allowed (positive control)memory_access_star_pattern_still_rejects_traversal— even["*"]rejects..candidates while still matching legitimatekv:anything/shared:scope/foouser_deny_is_case_insensitive,channel_deny_is_case_insensitive,categories_deny_is_case_insensitive— pin lowercase normalisationauth_denied_emits_audit_row_for_anonymous— boots a real kernel, callsauth_denied(&state, &extensions, ..), asserts aPermissionDeniedrow appears withoutcome="denied",channel="api",user_id=None, and the namespace reason indetailauth_denied_emits_audit_row_for_authenticated_user— same but withAuthenticatedApiUserextension; asserts attribution touser_idanonymous_fallback_*tests (from fix(security): RBAC M3 follow-up — memory ACL fail-closed for anonymous callers #3239) still pass — kept verbatim alongside the new onescargo build --workspace --lib/cargo test --workspace/cargo clippy -- -D warningsRefs: PR #3205 (RBAC M3), follow-up to #3239 (memory ACL fail-closed).