Files
zesdex/docs/superpowers/plans/2026-07-16-security-quickfixes.md
T

272 lines
14 KiB
Markdown

# Security Quick-Fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix the two standalone, low-risk findings from the 2026-07-16 audit that don't require touching the OAuth/session/CMS architecture: the misleading doc-comment on the `bash` tool's credential-read behavior, and the rate limiter trusting client-controlled `X-Forwarded-For`/`X-Real-IP` headers.
**Architecture:** No structural changes. Both fixes are localized to a single file each.
**Tech Stack:** Rust, Cargo workspace (`zesdex-backend`, `zesdex-middleware`).
## Global Constraints
- No `#[allow(...)]` lint-bypass attributes may be introduced (workspace `Cargo.toml` denies `dead_code`/`unused`; CLAUDE.md forbids bypass annotations outright).
- Every new/changed `pub fn` needs an accurate doc comment (What/Flow/Why/Return per CLAUDE.md's Code Documentation section).
- Tests are inline `#[cfg(test)] mod tests` blocks in the same file, per CLAUDE.md.
- Run `cargo clippy --workspace --all-targets -- -D warnings` and `cargo test --workspace` before each commit in this plan.
---
### Task 1: Fix misleading doc comment on `Bash::run` re: credential reads
**Context:** The audit flagged `crates/zesdex-backend/src/tool/shell_filter/credentials.rs`'s `check_credential_read` as "dead code that contradicts CLAUDE.md's claim that shell_filter blocks credential leaks." On closer reading, this is **not a behavior bug**`crates/zesdex-backend/src/tool/shell.rs` has a deliberate, reasoned inline comment (lines 71-73) explaining that credential reads are intentionally allowed locally (the AI needs access; the real threat is committing secrets to a public repo, handled elsewhere). The actual defect is narrower: the doc comment on `run()` (lines 49-51) claims it calls `check_credential_read` when it doesn't, and CLAUDE.md's "Key Patterns" section overstates what `shell_filter` does. This task corrects both to match actual (intentional) behavior — it does **not** change runtime behavior.
**Files:**
- Modify: `crates/zesdex-backend/src/tool/shell.rs:47-59`
- Modify: `/mnt/code/zesdex/CLAUDE.md` (the "Shell safety" line under "Key Patterns")
- Modify: `crates/zesdex-backend/src/tool/shell_filter/credentials.rs` (module doc comment, to mark it as intentionally unused-by-`shell.rs` rather than implying it's wired in)
**Interfaces:**
- Consumes: nothing new.
- Produces: nothing new (doc-only change). No downstream task depends on this.
- [ ] **Step 1: Read the current state to confirm line numbers haven't drifted**
Run: `grep -n "check_credential_read\|Only gate destructive" crates/zesdex-backend/src/tool/shell.rs`
Expected output includes the doc comment around line 49 and the inline comment around line 71.
- [ ] **Step 2: Fix the stale doc comment on `run()`**
In `crates/zesdex-backend/src/tool/shell.rs`, replace:
```rust
/// Run a bash command (foreground or background) with safety filters and a timeout.
///
/// Flow: extract args → run `check_credential_read` then `check_git_destructive`
/// (bail if either rejects) → branch on `run_in_background`: if true, hand off
/// to the bg-bash subsystem and return the job ID; else spawn `bash -c`,
/// poll with `try_wait`, kill on timeout, format combined stdout+stderr.
///
/// Why: the safety filters run unconditionally so background jobs are also gated;
/// the timeout is enforced by polling the child rather than relying on a libc alarm
/// so cleanup stays in Rust.
///
/// Return: exit-code + elapsed-seconds summary line (plus captured output) for
/// foreground runs, or the job ID for background runs.
```
with:
```rust
/// Run a bash command (foreground or background) with a safety filter and a timeout.
///
/// Flow: extract args → run `check_git_destructive` (bail if it rejects) → branch on
/// `run_in_background`: if true, hand off to the bg-bash subsystem and return the
/// job ID; else spawn `bash -c`, poll with `try_wait`, kill on timeout, format
/// combined stdout+stderr.
///
/// Why: only destructive git operations are gated here — credential-file reads
/// (`~/.ssh/id_rsa`, `.netrc`, etc.) are deliberately NOT blocked, since the agent
/// often needs to read local config for legitimate debugging; the real leak vector
/// (committing secrets to a remote) is handled by git hooks/user review, not this
/// tool. `shell_filter::credentials::check_credential_read` exists but is
/// intentionally not called from here — see its module doc comment. The safety
/// filter runs unconditionally so background jobs are also gated; the timeout is
/// enforced by polling the child rather than relying on a libc alarm so cleanup
/// stays in Rust.
///
/// Return: exit-code + elapsed-seconds summary line (plus captured output) for
/// foreground runs, or the job ID for background runs.
```
- [ ] **Step 3: Mark `check_credential_read` as intentionally unused, not dead**
Read `crates/zesdex-backend/src/tool/shell_filter/credentials.rs` in full first:
Run: `cat crates/zesdex-backend/src/tool/shell_filter/credentials.rs`
Add a module-level doc comment at the top of the file (before any existing doc comment on `check_credential_read` itself — do not remove the existing function-level doc, just add context above it):
```rust
//! Credential-file-read detection.
//!
//! Not currently called from `tool::shell::Bash::run` — see that function's
//! doc comment for why credential reads are intentionally allowed. This
//! module is kept for callers that DO want to block credential reads (e.g.
//! a future sandboxed/untrusted-tool execution path) and is covered by its
//! own inline tests below.
```
- [ ] **Step 4: Fix CLAUDE.md's overstated claim**
In `/mnt/code/zesdex/CLAUDE.md`, find the line under "Key Patterns":
```
- **Shell safety** — `tool/shell_filter/` blocks credential leaks and destructive git commands.
```
Replace with:
```
- **Shell safety** — `tool/shell_filter/` blocks destructive git commands (`shell_filter::git::check_git_destructive`, called from `tool/shell.rs::Bash::run`). It also contains a `check_credential_read` detector for credential-file reads, but that one is intentionally NOT wired into `Bash::run` today — see the doc comment on `Bash::run` for why.
```
- [ ] **Step 5: Verify the crate still builds and lints clean**
Run: `cargo check -p zesdex-backend`
Expected: no errors (doc-only + comment changes).
Run: `cargo clippy -p zesdex-backend -- -D warnings`
Expected: no new warnings.
- [ ] **Step 6: Commit**
```bash
git add crates/zesdex-backend/src/tool/shell.rs crates/zesdex-backend/src/tool/shell_filter/credentials.rs CLAUDE.md
git commit -m "docs(shell): perbaiki doc comment shell_filter yang menyesatkan soal credential-read"
```
---
### Task 2: Stop trusting client-supplied `X-Forwarded-For`/`X-Real-IP` in the rate limiter
**Context:** `crates/zesdex-middleware/src/rate_limit.rs` derives its per-client bucket key from `X-Forwarded-For`/`X-Real-IP` headers before falling back to the real socket address. Since this middleware isn't behind a trusted reverse proxy today (confirmed: no proxy config anywhere in the workspace), any direct caller can forge these headers to get a fresh rate-limit bucket on every request. This crate is currently unused/orphaned (no axum server exists yet to mount it on — see the separate `2026-07-16-middleware-axum-server.md` plan for that), but the fix belongs here as a standalone code-correctness task since it doesn't depend on that server existing.
**Files:**
- Modify: `crates/zesdex-middleware/src/rate_limit.rs`
**Interfaces:**
- Consumes: nothing new.
- Produces: `RateLimiter`/`RateLimitLayer` public API unchanged in shape; only the client-id derivation logic changes. Any future caller (including the axum-server plan) must pass `ConnectInfo<SocketAddr>` — note this for that plan.
- [ ] **Step 1: Read the current implementation**
Run: `cat crates/zesdex-middleware/src/rate_limit.rs`
Confirm the client-id extraction logic (around lines 190-210 per the audit) checks `X-Forwarded-For` first, then `X-Real-IP`, then falls back to the connection's socket address.
- [ ] **Step 2: Write the failing test**
Add to the `#[cfg(test)] mod tests` block at the bottom of `crates/zesdex-middleware/src/rate_limit.rs` (create the block if none exists yet — confirm via the Step 1 read):
```rust
#[test]
fn client_id_ignores_spoofed_forwarded_headers_by_default() {
// A request carrying a spoofed X-Forwarded-For must NOT be treated
// as a distinct client from one with a different spoofed value —
// both should resolve to the same real socket address.
let socket_addr: std::net::SocketAddr = "127.0.0.1:9999".parse().unwrap();
let mut headers_a = axum::http::HeaderMap::new();
headers_a.insert("x-forwarded-for", "1.2.3.4".parse().unwrap());
let mut headers_b = axum::http::HeaderMap::new();
headers_b.insert("x-forwarded-for", "5.6.7.8".parse().unwrap());
let id_a = client_id(&headers_a, socket_addr, false);
let id_b = client_id(&headers_b, socket_addr, false);
assert_eq!(
id_a, id_b,
"client_id must key on the real socket address when trust_proxy_headers is false, \
not on attacker-controlled X-Forwarded-For"
);
}
#[test]
fn client_id_uses_forwarded_header_when_trust_enabled() {
// When explicitly told to trust a fronting proxy, the header value
// should be used (this is the opt-in, documented-risk path).
let socket_addr: std::net::SocketAddr = "127.0.0.1:9999".parse().unwrap();
let mut headers = axum::http::HeaderMap::new();
headers.insert("x-forwarded-for", "1.2.3.4".parse().unwrap());
let id = client_id(&headers, socket_addr, true);
assert_eq!(id, "1.2.3.4");
}
```
- [ ] **Step 3: Run the test to verify it fails**
Run: `cargo test -p zesdex-middleware client_id_ignores_spoofed -- --nocapture`
Expected: compile error (`client_id` doesn't yet take a `trust_proxy_headers: bool` parameter) or, if the function already exists without that parameter, a straightforward assertion failure since headers are currently trusted unconditionally.
- [ ] **Step 4: Add a `trust_proxy_headers` flag and make header-trust opt-in**
Locate the existing client-id derivation function (from Step 1) and change its signature to take an explicit trust flag, defaulting callers to `false`. Replace the header-first logic with:
```rust
/// Derive the rate-limit bucket key for one request.
///
/// Flow: if `trust_proxy_headers` is true, use `X-Forwarded-For` (first
/// hop) then `X-Real-IP`; otherwise always use the real connection
/// socket address, ignoring any client-supplied headers.
///
/// Why: without a trusted reverse proxy stripping/overwriting these
/// headers, they are attacker-controlled — trusting them by default lets
/// any direct caller reset their own rate-limit bucket on every request.
/// `trust_proxy_headers` must only be set to `true` when this middleware
/// sits behind a proxy that is known to overwrite (not merge) these headers.
fn client_id(
headers: &axum::http::HeaderMap,
socket_addr: std::net::SocketAddr,
trust_proxy_headers: bool,
) -> String {
if trust_proxy_headers {
if let Some(fwd) = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.map(str::trim)
{
if !fwd.is_empty() {
return fwd.to_string();
}
}
if let Some(real_ip) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) {
if !real_ip.is_empty() {
return real_ip.to_string();
}
}
}
socket_addr.ip().to_string()
}
```
Update every call site of the old client-id function within `rate_limit.rs` (the `Service::call`/`poll_ready` implementation that extracts headers and the socket address from the incoming `Request`) to pass `false` for `trust_proxy_headers` for now, with a `// TODO` is NOT allowed per project convention — instead add it as a named constructor parameter on `RateLimiter`/`RateLimitLayer` so callers decide explicitly:
```rust
impl RateLimiter {
/// Construct a rate limiter that keys strictly on the real connection
/// socket address (default, safe when not behind a trusted proxy).
pub fn new(/* existing params */) -> Self {
Self::with_proxy_trust(/* existing args */, false)
}
/// Construct a rate limiter that additionally trusts
/// `X-Forwarded-For`/`X-Real-IP` headers — only use this when the
/// middleware is mounted behind a reverse proxy known to overwrite
/// (not merge) these headers before they reach this service.
pub fn with_proxy_trust(/* existing params */, trust_proxy_headers: bool) -> Self {
// existing construction logic, storing trust_proxy_headers on self
}
}
```
(Exact existing constructor parameters depend on `RateLimiter`'s current fields, visible from the Step 1 read — thread `trust_proxy_headers: bool` through as an additional stored field alongside them.)
- [ ] **Step 5: Run the tests to verify they pass**
Run: `cargo test -p zesdex-middleware client_id -- --nocapture`
Expected: both new tests pass.
- [ ] **Step 6: Run the full middleware test suite and clippy**
Run: `cargo test -p zesdex-middleware && cargo clippy -p zesdex-middleware -- -D warnings`
Expected: all pass, no new warnings.
- [ ] **Step 7: Commit**
```bash
git add crates/zesdex-middleware/src/rate_limit.rs
git commit -m "fix(middleware): jangan percaya header X-Forwarded-For/X-Real-IP secara default di rate limiter"
```