Skip to content

fix(security): escape untrusted input at Cedar + ClickHouse (ARN-172, ARN-174)#346

Open
rita-aga wants to merge 1 commit into
mainfrom
claude/arn-172-174-injection-escaping
Open

fix(security): escape untrusted input at Cedar + ClickHouse (ARN-172, ARN-174)#346
rita-aga wants to merge 1 commit into
mainfrom
claude/arn-172-174-injection-escaping

Conversation

@rita-aga

@rita-aga rita-aga commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Closes ARN-172 and ARN-174 under epic ARN-165 — two injection fixes with a shared theme (untrusted input into a query/policy language). Draft for review — do not merge.

ARN-172 — Cedar policy injection (temper-authz/src/policy_gen.rs)

generate_cedar_from_matrix interpolated agent-influenced ids (agent_id/action/resource/role, ultimately from spoofable headers via PendingDecision::from_denial) straight into Cedar policy text. A " or \ could break the tenant policy reload or craft a permit whose meaning ≠ what the human approved — defeating the approval gate.

Fix: stop hand-formatting. Emit UIDs via Cedar's own EntityId::new(v).escaped() (cedar_uid/cedar_type) and string conditions via cedar_string_literal, so every interpolated value is Cedar-escaped by construction. Callers updated to the new API (decisions.rs, pending_decisions.rs, policy_suggestions.rs, hooks).

ARN-174 — ClickHouse SQL injection (temper-observe/src/clickhouse.rs)

interpolate_params doubled single quotes but not backslashes, so a value ending in \ left the literal unterminated and the next param executed as live SQL.

Fix: s.replace('\\', "\\\\").replace('\'', "''") — escape backslash first, then quote.

Red-green

  • injected_quote_and_backslash_in_agent_and_action_round_trip, injected_backslash_in_resource_id_does_not_break_reload: a "/\ in an id now produces a valid, correctly-scoped policy (pre-fix, one exploit input parsed to two policies — a live broad-permit injection).
  • interpolate_escapes_trailing_backslash_so_next_param_stays_quoted: asserts the injection is neutralized ('x\\' AND b = ' OR 1=1 --').
  • Suites green: temper-authz 74, temper-observe 61 (full workspace suite passed at push).

Notes

  • ADR-0158. Larger refactor than a minimal escape (policy_gen restructured around typed emitters) — deliberate, so escaping can't be forgotten at a future call site.

🤖 Generated with Claude Code

Greptile Summary

Fixes two injection vulnerabilities (ARN-172 and ARN-174) that allowed agent-controlled strings to break out of Cedar policy literals and ClickHouse SQL string parameters respectively. The Cedar fix restructures generate_cedar_from_matrix around Cedar's own typed constructors (EntityId, EntityTypeName, EntityUid) instead of raw string formatting, and changes the return type to Result<String, String> so invalid type names fail closed. The ClickHouse fix adds backslash escaping (replace('\\', "\\\\")) before the existing single-quote doubling, fixing the case where a value ending in \\ left the literal unterminated.

  • Cedar (ARN-172): New cedar_uid, cedar_type, and cedar_string_literal helpers delegate all escaping to Cedar itself; all five injection-position sites updated; callers in decisions.rs, pending_decisions.rs, policy_suggestions.rs, and both hook modules updated to handle the new fallible return.
  • ClickHouse (ARN-174): One-line fix in interpolate_params; backslash must be escaped first so the \\\\ introduced for ' escaping is not re-escaped; two new regression tests cover the trailing-backslash breakout and the combined \\' case.
  • Tests: Eight new injection-resistance tests in policy_gen_test.rs use Cedar's own parser to verify round-trip confinement; one new ClickHouse test asserts the injection is neutralized end-to-end.

Confidence Score: 4/5

Both injection vulnerabilities are correctly neutralised; the Cedar and ClickHouse fixes are well-scoped, well-tested, and safe to merge.

The Cedar fix delegates escaping entirely to Cedar's own typed constructors rather than hand-rolling it, which is the right approach. The ClickHouse fix applies the correct ordering (backslash before quote) and is covered by regression tests that assert the exact rendered output. All callers handle the new fallible return correctly. The two observations are non-blocking: the in_string template scanner gap with backslash-escaped quotes is pre-existing with no active exposure in current templates, and the agent/action round-trip assertion gap in the test is a thoroughness point rather than a missing safety check.

crates/temper-observe/src/clickhouse.rs — the in_string scanner behaviour relative to backslash-escaped quotes in template SQL is worth a follow-up; no active exposure today.

Important Files Changed

Filename Overview
crates/temper-authz/src/policy_gen.rs Core Cedar policy generator refactored to use Cedar's own typed constructors for all agent-controlled positions; generate_cedar_from_matrix changed to Result<String, String> for fail-closed type-name validation
crates/temper-observe/src/clickhouse.rs Backslash escape added before quote-doubling in interpolate_params; in_string template scanner still does not handle \' in template SQL (pre-existing gap noted in comments)
crates/temper-authz/src/policy_gen_test.rs Comprehensive injection-resistance tests added; round-trip verification uses Cedar's own parser and EST JSON walk to confirm confinement and exact value preservation
crates/temper-server/src/api/decisions.rs Caller updated to match-arm on the fallible generate_policy_from_matrix; returns 400 BAD_REQUEST on invalid type name, correctly fails closed
crates/temper-server/src/state/policy_suggestions.rs Both generate_cedar_from_matrix call sites updated; an invalid resource_type now skips the suggestion with a warning rather than panicking or producing a broken preview
crates/temper-server/src/state/pending_decisions.rs Wrapper generate_policy_from_matrix signature updated to propagate Result; no logic changes
crates/temper-platform/src/hooks.rs Hook updated to map_err on the fallible generate_cedar_from_matrix call; error surfaces cleanly via ?
crates/temper-platform/src/hooks/generate_cedar.rs Same map_err update as hooks.rs for the alternate hook entry point
docs/adrs/0158-injection-escaping.md New ADR documenting both injection bugs, the chosen fixes, alternatives considered, and the non-goals (ClickHouse native binding, request-time UID path)

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Agent as Agent (spoofable headers)
    participant PD as PendingDecision
    participant DEC as decisions.rs (approve)
    participant PG as policy_gen.rs
    participant CH as ClickHouse

    Agent->>PD: denial via from_denial(action, resource_id, …)
    PD->>DEC: "generate_policy_from_matrix(&scope)"
    DEC->>PG: generate_cedar_from_matrix(agent_id, principal_kind, action, resource_type, resource_id, matrix)

    note over PG: ARN-172 fix
    PG->>PG: cedar_type(principal_kind) — validates identifier, fails closed on injection
    PG->>PG: cedar_uid(Action, action) — Cedar escapes action id
    PG->>PG: cedar_uid(resource_type, resource_id) — Cedar escapes resource id
    PG->>PG: cedar_string_literal(role/agentType/sessionId) — Cedar escapes conditions
    PG-->>DEC: Ok(policy_text) or Err(…)

    alt type name invalid
        DEC-->>Agent: 400 BAD_REQUEST (fail closed)
    else policy generated
        DEC->>DEC: validate + reload tenant policies
    end

    note over CH: ARN-174 fix
    Agent->>CH: query with SqlParam::String(user_value)
    CH->>CH: interpolate_params: replace backslash then replace quote
    CH->>CH: $N placeholder replaced only outside quoted literals
    CH-->>Agent: query result
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Agent as Agent (spoofable headers)
    participant PD as PendingDecision
    participant DEC as decisions.rs (approve)
    participant PG as policy_gen.rs
    participant CH as ClickHouse

    Agent->>PD: denial via from_denial(action, resource_id, …)
    PD->>DEC: "generate_policy_from_matrix(&scope)"
    DEC->>PG: generate_cedar_from_matrix(agent_id, principal_kind, action, resource_type, resource_id, matrix)

    note over PG: ARN-172 fix
    PG->>PG: cedar_type(principal_kind) — validates identifier, fails closed on injection
    PG->>PG: cedar_uid(Action, action) — Cedar escapes action id
    PG->>PG: cedar_uid(resource_type, resource_id) — Cedar escapes resource id
    PG->>PG: cedar_string_literal(role/agentType/sessionId) — Cedar escapes conditions
    PG-->>DEC: Ok(policy_text) or Err(…)

    alt type name invalid
        DEC-->>Agent: 400 BAD_REQUEST (fail closed)
    else policy generated
        DEC->>DEC: validate + reload tenant policies
    end

    note over CH: ARN-174 fix
    Agent->>CH: query with SqlParam::String(user_value)
    CH->>CH: interpolate_params: replace backslash then replace quote
    CH->>CH: $N placeholder replaced only outside quoted literals
    CH-->>Agent: query result
Loading

Comments Outside Diff (2)

  1. crates/temper-observe/src/clickhouse.rs, line 71-84 (link)

    P2 in_string scanner doesn't account for \' in template SQL

    The render fix correctly doubles backslashes so ClickHouse sees a terminated literal. However, the template scanner's in_string state machine only recognises '' as an in-string quote escape (line 74) — it does not handle \'. If a programmer-written template ever contains a \'-escaped quote inside a string literal (valid ClickHouse syntax), the scanner would treat the ' following \ as a string terminator and toggle in_string to false too early. Any $N placeholder appearing after that point would be substituted outside a string context even though ClickHouse's parser is still inside one. This is a pre-existing gap, but the ADR explicitly calls out that ClickHouse honors C-style backslash escapes, making it worth recording as a follow-up. Templates in the current codebase use ''-style quoting so there is no active exposure; the scanner could be hardened by tracking \ as an escape prefix when in_string is true.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: crates/temper-observe/src/clickhouse.rs
    Line: 71-84
    
    Comment:
    **`in_string` scanner doesn't account for `\'` in template SQL**
    
    The `render` fix correctly doubles backslashes so ClickHouse sees a terminated literal. However, the template scanner's `in_string` state machine only recognises `''` as an in-string quote escape (line 74) — it does not handle `\'`. If a programmer-written template ever contains a `\'`-escaped quote inside a string literal (valid ClickHouse syntax), the scanner would treat the `'` following `\` as a string terminator and toggle `in_string` to `false` too early. Any `$N` placeholder appearing after that point would be substituted outside a string context even though ClickHouse's parser is still inside one. This is a pre-existing gap, but the ADR explicitly calls out that ClickHouse honors C-style backslash escapes, making it worth recording as a follow-up. Templates in the current codebase use `''`-style quoting so there is no active exposure; the scanner could be hardened by tracking `\` as an escape prefix when `in_string` is true.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code Fix in Codex Fix in Cursor

  2. crates/temper-authz/src/policy_gen_test.rs, line 643-651 (link)

    P2 Agent and action round-trip not verified by expect_resource_eq

    injected_quote_and_backslash_in_agent_and_action_round_trip verifies that the generated text parses as exactly one policy and that the resource constraint round-trips correctly — but it does not assert that bad_agent or bad_action also round-trip to their principal/action constraints. Injection containment is proven by parse_single_policy (exactly one policy, no breakout), but adding policy.principal_constraint() and policy.action_groups() assertions analogous to expect_resource_eq would give the same degree of structural proof for the agent and action positions that the resource tests already provide.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: crates/temper-authz/src/policy_gen_test.rs
    Line: 643-651
    
    Comment:
    **Agent and action round-trip not verified by `expect_resource_eq`**
    
    `injected_quote_and_backslash_in_agent_and_action_round_trip` verifies that the generated text parses as exactly one policy and that the resource constraint round-trips correctly — but it does not assert that `bad_agent` or `bad_action` also round-trip to their principal/action constraints. Injection containment is proven by `parse_single_policy` (exactly one policy, no breakout), but adding `policy.principal_constraint()` and `policy.action_groups()` assertions analogous to `expect_resource_eq` would give the same degree of structural proof for the agent and action positions that the resource tests already provide.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

    Fix in Claude Code Fix in Codex Fix in Cursor

Fix All in Claude Code Fix All in Codex Fix All in Cursor

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
crates/temper-observe/src/clickhouse.rs:71-84
**`in_string` scanner doesn't account for `\'` in template SQL**

The `render` fix correctly doubles backslashes so ClickHouse sees a terminated literal. However, the template scanner's `in_string` state machine only recognises `''` as an in-string quote escape (line 74) — it does not handle `\'`. If a programmer-written template ever contains a `\'`-escaped quote inside a string literal (valid ClickHouse syntax), the scanner would treat the `'` following `\` as a string terminator and toggle `in_string` to `false` too early. Any `$N` placeholder appearing after that point would be substituted outside a string context even though ClickHouse's parser is still inside one. This is a pre-existing gap, but the ADR explicitly calls out that ClickHouse honors C-style backslash escapes, making it worth recording as a follow-up. Templates in the current codebase use `''`-style quoting so there is no active exposure; the scanner could be hardened by tracking `\` as an escape prefix when `in_string` is true.

### Issue 2 of 2
crates/temper-authz/src/policy_gen_test.rs:643-651
**Agent and action round-trip not verified by `expect_resource_eq`**

`injected_quote_and_backslash_in_agent_and_action_round_trip` verifies that the generated text parses as exactly one policy and that the resource constraint round-trips correctly — but it does not assert that `bad_agent` or `bad_action` also round-trip to their principal/action constraints. Injection containment is proven by `parse_single_policy` (exactly one policy, no breakout), but adding `policy.principal_constraint()` and `policy.action_groups()` assertions analogous to `expect_resource_eq` would give the same degree of structural proof for the agent and action positions that the resource tests already provide.

Reviews (2): Last reviewed commit: "fix(security): escape untrusted input at..." | Re-trigger Greptile

Two related injection fixes at Temper's untrusted-input boundaries; one ADR
(docs/adrs/0158-injection-escaping.md).

ARN-172 (Cedar policy injection): generate_cedar_from_matrix built Cedar policy
text with format!, splicing agent-influenced agent_id/action/resource_type/
resource_id/role/agent_type/session_id (ultimately from spoofable x-temper-*
headers) with no escaping. A `"` or `\` could break the generated permit
(failing the whole tenant policy reload) or inject a second, broader permit —
defeating the human approval gate. Now build UID positions via Cedar's own
EntityUid/EntityTypeName (validating and rejecting non-identifier type names)
and render condition string literals via Cedar's EntityId::escaped(). The
function is now fallible and fails closed; callers propagate/surface the error
(hooks.rs, hooks/generate_cedar.rs, api/decisions.rs -> 400,
state/policy_suggestions.rs skips the advisory preview).

ARN-174 (ClickHouse SQL injection): interpolate_params doubled single quotes
only; ClickHouse also honors C-style backslash escapes, so a value ending in
`\` escaped the closing quote and let the next param execute as live SQL. Now
escape `\` before doubling `'`.

Exploit-first tests are red on the prior code (one parsed to two policies — a
live broad-permit injection; others hit a parse error that breaks the reload)
and green after, plus fail-closed and exact-value round-trip tests.

Closes ARN-172, ARN-174.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@rita-aga rita-aga marked this pull request as ready for review July 7, 2026 08:10
@rita-aga

rita-aga commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

@greptile review

Comment on lines +238 to +244
policies.into_iter().next().unwrap()
}

fn expect_resource_eq(policy: &cedar_policy::Policy, type_name: &str, id: &str) {
use std::str::FromStr;
let expected = cedar_policy::EntityUid::from_type_name_and_id(
cedar_policy::EntityTypeName::from_str(type_name).unwrap(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Two bare .unwrap() calls in test helpers — parse_single_policy (line 238) and expect_resource_eq (line 244) — may trip the repo's pre-commit integrity check (no TODO/unwrap). The parse_single_policy case is technically safe because assert_eq!(policies.len(), 1) guarantees the next() succeeds, but the hook does textual matching. Both should use .expect(…) with a message to be consistent with the rest of the file and stay clear of the gate.

Suggested change
policies.into_iter().next().unwrap()
}
fn expect_resource_eq(policy: &cedar_policy::Policy, type_name: &str, id: &str) {
use std::str::FromStr;
let expected = cedar_policy::EntityUid::from_type_name_and_id(
cedar_policy::EntityTypeName::from_str(type_name).unwrap(),
policies.into_iter().next().expect("Vec had exactly 1 element per assert_eq above")
}
fn expect_resource_eq(policy: &cedar_policy::Policy, type_name: &str, id: &str) {
use std::str::FromStr;
let expected = cedar_policy::EntityUid::from_type_name_and_id(
cedar_policy::EntityTypeName::from_str(type_name)
.expect("type_name in test helper must be a valid Cedar identifier"),
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/temper-authz/src/policy_gen_test.rs
Line: 238-244

Comment:
Two bare `.unwrap()` calls in test helpers — `parse_single_policy` (line 238) and `expect_resource_eq` (line 244) — may trip the repo's pre-commit integrity check (`no TODO/unwrap`). The `parse_single_policy` case is technically safe because `assert_eq!(policies.len(), 1)` guarantees the `next()` succeeds, but the hook does textual matching. Both should use `.expect(…)` with a message to be consistent with the rest of the file and stay clear of the gate.

```suggestion
    policies.into_iter().next().expect("Vec had exactly 1 element per assert_eq above")
}

fn expect_resource_eq(policy: &cedar_policy::Policy, type_name: &str, id: &str) {
    use std::str::FromStr;
    let expected = cedar_policy::EntityUid::from_type_name_and_id(
        cedar_policy::EntityTypeName::from_str(type_name)
            .expect("type_name in test helper must be a valid Cedar identifier"),
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex Fix in Cursor

@rita-aga

Copy link
Copy Markdown
Collaborator Author

ARN-165 principle audit — request changes and split the PR

This combines two independent security issues (ARN-172 and ARN-174), contrary to the one-issue/one-PR discipline, and neither root boundary is complete.

Cedar blocker

generate_cedar_from_matrix relies on debug_assert! for security-relevant companion-field invariants and does not invoke validate_policy_scope_matrix at the generation/persistence sink. In release builds, a caller can omit restrictions and receive a syntactically valid but weaker policy.

Validation must be mandatory inside the authoritative generator/persist boundary, return a typed error, and have release-equivalent negative tests. Call-site validation is defense in depth, not the invariant.

ClickHouse blocker

The replacement is still a custom SQL tokenizer/escaper. That preserves a parallel SQL grammar which can drift on comments, string forms, identifiers, escapes, and future dialect features. The durable boundary is driver-side typed parameters, or a deliberately restricted typed query AST whose renderer is the only SQL construction path. Remove the generic interpolation fallback.

Please split the Cedar and ClickHouse changes, add adversarial/fuzz coverage to each, and keep both issues open until their independent contracts are proven.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant