Skip to content

fix(security): RBAC M3 follow-up — namespace traversal + case-insensitive deny + memory audit emit (#3205)#3249

Merged
houko merged 3 commits into
mainfrom
followup/3205-memory-acl-hardening
Apr 26, 2026
Merged

fix(security): RBAC M3 follow-up — namespace traversal + case-insensitive deny + memory audit emit (#3205)#3249
houko merged 3 commits into
mainfrom
followup/3205-memory-acl-hardening

Conversation

@houko

@houko houko commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

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 to capability::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") -> true
  • glob_matches("kv:*", "kv:user_../admin") -> true

A 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_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 — 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 intentional).

Item #8a — Case-sensitive deny lists — PARTIAL -> fixed

UserToolPolicy, ChannelToolPolicy, and UserToolCategories matched tool names case-sensitively (crates/librefang-types/src/user_policy.rs:151,111,195):

  • glob_matches("shell_exec", "SHELL_EXEC") -> false

Built-in tools dispatch by exact-case match (tool_runner.rs:1041) so a SHELL_EXEC invocation 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_tool methods. 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 every LibreFangError::AuthDenied from the namespace guard) returned a 403 without recording a PermissionDenied audit row. Meanwhile every other admin-gated RBAC endpoint emits one:

  • routes/audit.rs::require_admin — emits PermissionDenied
  • routes/budget.rs:468,482 — emits PermissionDenied
  • routes/authz.rs:60,74 — emits PermissionDenied
  • middleware.rs:435,765 — emits PermissionDenied

So a privilege probe against /api/memory* was invisible to admins reading /api/audit.

Fix. auth_denied now takes &AppState + &Extensions and writes a PermissionDenied row via state.kernel.audit().record_with_context(...) before returning 403. Anonymous callers -> user_id = None; authenticated-but-denied callers -> attributed user_id plus user / role in the detail string.

Files changed

  • crates/librefang-types/src/user_policy.rsnamespace_glob_matches + has_path_traversal + case-insensitive check_tool on UserToolPolicy / ChannelToolPolicy / UserToolCategories + tests
  • crates/librefang-api/src/routes/memory.rsauth_denied audit emit + tests

Test plan

  • memory_access_namespace_blocks_path_traversalkv:user_../admin, kv:user_alice/../bob, and separator-crossing globs all denied; kv:user_alice still allowed (positive control)
  • memory_access_star_pattern_still_rejects_traversal — even ["*"] rejects .. candidates while still matching legitimate kv:anything / shared:scope/foo
  • 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 — boots a real kernel, calls auth_denied(&state, &extensions, ..), asserts a PermissionDenied row appears with outcome="denied", channel="api", user_id=None, and the namespace reason in detail
  • auth_denied_emits_audit_row_for_authenticated_user — same but with AuthenticatedApiUser extension; asserts attribution to user_id
  • Existing anonymous_fallback_* tests (from fix(security): RBAC M3 follow-up — memory ACL fail-closed for anonymous callers #3239) still pass — kept verbatim alongside the new ones
  • CI verifies cargo build --workspace --lib / cargo test --workspace / cargo clippy -- -D warnings

Refs: PR #3205 (RBAC M3), follow-up to #3239 (memory ACL fail-closed).

…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.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

houko added 2 commits April 26, 2026 23:44
#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.
@houko
houko merged commit 961f26d into main Apr 26, 2026
13 checks passed
@houko
houko deleted the followup/3205-memory-acl-hardening branch April 26, 2026 14:57
@github-actions github-actions Bot added size/L 250-999 lines changed ready-for-review PR is ready for maintainer review and removed ready-for-review PR is ready for maintainer review labels Apr 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L 250-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant