Skip to content

fix(snapshot): reject manifest path traversal - #107

Merged
steipete merged 2 commits into
openclaw:mainfrom
SebTardif:fix/snapshot-import-rootdir
Sep 5, 2026
Merged

fix(snapshot): reject manifest path traversal#107
steipete merged 2 commits into
openclaw:mainfrom
SebTardif:fix/snapshot-import-rootdir

Conversation

@SebTardif

@SebTardif SebTardif commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

What Problem This Solves

A snapshot manifest can list ../outside.jsonl.gz in Files or legacy File, causing full and incremental imports to read a gzip JSONL shard outside the caller-selected root and insert its rows into SQLite.

What Changed

Validate manifest shard paths with Go's platform-aware filepath.IsLocal before opening them. Preserve literal root and shard names, including whitespace and current-directory roots (., ./, and the existing empty-root behavior). This repairs the original patch's root/shard trimming and current-directory regressions while retaining its shared import guard and regression coverage.

The check is lexical. Existing symlink following remains unchanged and is documented in the README; callers must supply a trusted filesystem tree. This PR does not claim symlink-safe confinement.

The unreleased notes credit @SebTardif and include all user-visible changes since v0.14.8, including the already-merged GitHub Script refresh.

Validation

  • Real public snapshot.Import and snapshot.ImportIncremental calls against temporary gzip JSONL fixtures and SQLite databases, on macOS arm64 with Go 1.27.1.
  • Baseline main accepted parent traversal and imported the synthetic foreign row. The repaired code rejects parent, absolute, and legacy-parent paths, leaving the preexisting destination row intact.
  • Both APIs import the intended row for nested shards, ., ./, empty roots, whitespace-bearing roots, and whitespace-bearing shard names; sibling/trimmed-name decoys never supply rows.
  • Regression coverage for both APIs and legacy filenames; Windows skips only trailing-whitespace filename cases that its filesystem normalizes.
  • GOMAXPROCS=2 make check, actionlint, independent Codex review, and exact-head CI are recorded in the proof comment.

Contributor: Sebastien Tardif SebTardif@ncf.ca.

Import joined manifest Files and legacy File entries onto RootDir
and opened the result. A git-shared or hand-edited manifest.json
with ../ or an absolute path could read a gzip JSONL outside the
snapshot and insert those rows.

Resolve each path, require it stay under RootDir, and reject
absolute and parent-directory entries. Export already rejects
unsafe table names via tableShardDir.

Signed-off-by: Sebastien Tardif <SebTardif@ncf.ca>
@clawsweeper

clawsweeper Bot commented Sep 5, 2026

Copy link
Copy Markdown

🦞👀
ClawSweeper picked this up.

Pull request received. I will update this pull request when review starts.

ClawSweeper review complete

ClawSweeper finished reviewing this revision. The review result is being finalized.

View the workflow run.

@clawsweeper clawsweeper Bot added P2 Normal priority bug or improvement with limited blast radius. merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. merge-risk: 🚨 security-boundary 🚨 Merging this PR could weaken sandboxing, authorization, credentials, or sensitive data. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Sep 5, 2026
@clawsweeper

clawsweeper Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codex review: blocked before merge. Reviewed September 5, 2026, 2:23 PM ET / 18:23 UTC.

ClawSweeper review

What this changes

Validate snapshot shard paths before full and incremental imports read them, preserving literal filenames and current-directory roots while documenting the existing symlink boundary.

Merge readiness

Blocked before merge - 1 item remains

This remains a useful fix: current main and v0.14.8 still permit parent traversal during snapshot imports. The revised implementation resolves both previous findings, and no new blocking defect was found.

Priority: P2
Reviewed head: e69632540ccdc007eaf88c20d5874a65ae59642a

Review scores

Measure Result What it means
Overall readiness 🐚 platinum hermit (4/6) A focused shared fix with resolved prior findings, meaningful compatibility coverage, and reported real-path before/after results.
Proof confidence 🐚 platinum hermit (4/6) Sufficient (live_output): The captured body reports real full and incremental imports on macOS with gzip files and SQLite: escaping paths fail while existing rows survive, and allowed literal paths import intended rows without decoys. This addresses the prior authority-boundary proof gap; the supporting transcript was inaccessible to this reviewer.
Patch quality 🐚 platinum hermit (4/6) No actionable review findings were identified.

Verification

Check Result Evidence
Real behavior Verified Sufficient (live_output): The captured body reports real full and incremental imports on macOS with gzip files and SQLite: escaping paths fail while existing rows survive, and allowed literal paths import intended rows without decoys. This addresses the prior authority-boundary proof gap; the supporting transcript was inaccessible to this reviewer.
Evidence reviewed 8 items Repository policy and ownership: Read the complete root AGENTS.md and docs/boundary.md. Shared snapshot mechanics belong here; compatibility and temporary-data validation apply. No nested snapshot AGENTS.md or maintainer-notes directory was found.
Current main still needs the guard: The main-branch importTable joins manifest paths to the root and opens the result without checking locality; ../ can escape the selected directory.
Latest release has the same unguarded read: The supplied latest release, v0.14.8, also joins and opens manifest shard paths without the proposed validation.
Findings None None.
Security None None.

How this fits together

The shared snapshot package imports compressed archive shards into downstream applications’ SQLite databases. Manifest paths select the files, and full and incremental imports share the same shard reader.

flowchart TD
 A[Snapshot manifest] --> B[Full or incremental import]
 C[Caller-selected root] --> D[Lexical path validation]
 B --> D
 D -->|Unsafe path| E[Error and transaction rollback]
 D -->|Local path| F[Read compressed shard]
 F --> G[Import rows into SQLite]
Loading

Before merge

  • Resolve merge risk (P1) - The supporting runtime transcript could not be independently retrieved; the detailed captured PR body supplies the observed results used in this review.
Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Production and test growth Production +12 net lines; tests +196 lines The small shared guard has a clear security purpose, with regression coverage for rejection and compatibility.
Compatibility scenarios 7 scenarios across 2 import APIs Coverage exercises literal paths, legacy filenames, and current-directory roots affected by the earlier findings.

Merge-risk options

Maintainer options:

  1. Decide the mitigation before merge
    Keep one shared lexical guard before shard reads, preserve valid legacy paths, and retain the explicit trusted-filesystem requirement.
  2. Pause or close
    Do not merge this PR until maintainers decide whether the risk is worth taking.

Technical review

Best possible solution:

Keep one shared lexical guard before shard reads, preserve valid legacy paths, and retain the explicit trusted-filesystem requirement.

Do we have a high-confidence way to reproduce the issue?

Yes: current main directly opens a joined ../ shard path, establishing a source-level reproduction with an outside gzip JSONL file. This read-only review did not execute it; the captured body reports a successful baseline reproduction.

Is this the best way to solve the issue?

Yes: validation in the shared reader protects both import APIs without duplicating their logic or changing the manifest format. The revised lexical check preserves legitimate paths and accurately documents its symlink limitation.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning medium; reviewed against 63101e688304.

Labels

Label changes:

  • add proof: sufficient: Contributor real behavior proof is sufficient. The captured body reports real full and incremental imports on macOS with gzip files and SQLite: escaping paths fail while existing rows survive, and allowed literal paths import intended rows without decoys. This addresses the prior authority-boundary proof gap; the supporting transcript was inaccessible to this reviewer.
  • add rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🐚 platinum hermit and patch quality is 🐚 platinum hermit. Replaced prior rating: 🦐 gold shrimp.
  • remove rating: 🦐 gold shrimp: Current PR rating is rating: 🐚 platinum hermit, so this older rating label is no longer current.
  • remove merge-risk: 🚨 compatibility: Current PR review selected no merge-risk labels.
  • remove merge-risk: 🚨 security-boundary: Current PR review selected no merge-risk labels.
  • remove status: 📣 needs proof: Current PR status no longer selects a status label.

Label justifications:

  • P2: This is a focused snapshot-import hardening fix with a clear owned path and no evidence of an active widespread incident.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🐚 platinum hermit and patch quality is 🐚 platinum hermit. Replaced prior rating: 🦐 gold shrimp.
  • proof: sufficient: Contributor real behavior proof is sufficient. The captured body reports real full and incremental imports on macOS with gzip files and SQLite: escaping paths fail while existing rows survive, and allowed literal paths import intended rows without decoys. This addresses the prior authority-boundary proof gap; the supporting transcript was inaccessible to this reviewer.

Evidence

What I checked:

  • Repository policy and ownership: Read the complete root AGENTS.md and docs/boundary.md. Shared snapshot mechanics belong here; compatibility and temporary-data validation apply. No nested snapshot AGENTS.md or maintainer-notes directory was found. (AGENTS.md:1, e69632540ccd)
  • Current main still needs the guard: The main-branch importTable joins manifest paths to the root and opens the result without checking locality; ../ can escape the selected directory. (snapshot/snapshot.go:515, 63101e688304)
  • Latest release has the same unguarded read: The supplied latest release, v0.14.8, also joins and opens manifest shard paths without the proposed validation. (snapshot/snapshot.go:515, 5cdee495743f)
  • Shared validation precedes file access: Both import APIs reach importTable. The introduced guard rejects nonlocal, dot-only, and NUL-containing paths before os.Open, without trimming root or shard names. Existing transaction rollback remains intact. (snapshot/snapshot.go:515, e69632540ccd)
  • Earlier findings resolved: The captured previous review identified literal-root normalization and current-directory rejection. Current source removes both behaviors; the new test covers seven path scenarios through each public import API, including whitespace decoys and legacy filenames. The earlier revision's blob was unavailable for a direct historical diff, so no late-finding attribution is made. (snapshot/import_paths_test.go:13, e69632540ccd)
  • Captured real API results: The complete supplied PR body, under context sourceRevision 49f833a185867f104cee29ec265f4b39c0498f20804df8d317017d49a7fabfcb, reports macOS arm64/Go 1.27.1 runs using actual public import APIs, gzip files, and SQLite databases. It records baseline foreign-row ingestion, after-fix rejection with existing destination rows preserved, and intended-row success without decoy ingestion for both APIs. These address the previous proof request. The referenced supporting proof comment could not be retrieved through either GitHub API or browser access; this assessment relies on the captured body rather than an independently inspected transcript. (e69632540ccd)

Likely related people:

  • steipete: Suggested for follow-up; no historical authorship or introduction is verified. (role: unverified routing candidate; confidence: low)
  • Vincent Koc: Suggested for follow-up; no historical authorship or introduction is verified. (role: unverified routing candidate; confidence: low)

Rating scale

Score Internal tier Crab rank Meaning
6/6 S 🦀 challenger crab Exceptional readiness
5/6 A 🦞 diamond lobster Very strong readiness
4/6 B 🐚 platinum hermit Good normal PR; ordinary maintainer review
3/6 C 🦐 gold shrimp Useful, but confidence is limited
2/6 D 🦪 silver shellfish Proof or implementation needs work
1/6 F 🧂 unranked krab Not merge-ready
N/A NA 🌊 off-meta tidepool Rating does not apply

Overall follows the weaker of proof and patch quality.
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

Workflow

  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

History

Review history (1 earlier review cycle)
  • reviewed 2026-09-05T05:46:26.309Z sha b3384a1 :: needs real behavior proof before merge. :: [P1] [P1] Preserve the literal snapshot root when resolving shards | [P1] [P1] Allow current-directory snapshot roots

Use Go's platform-aware lexical path guard without trimming the caller's
root or shard names. Preserve current-directory imports and cover both
public import APIs with intended-root and decoy-shard fixtures.

Document the existing symlink trust boundary and prepare the complete
v0.14.9 unreleased notes.
@steipete steipete changed the title fix(snapshot): confine Import file paths under RootDir fix(snapshot): reject manifest path traversal Sep 5, 2026
@steipete

steipete commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Maintainer proof for e69632540ccdc007eaf88c20d5874a65ae59642a.

The original parent-directory traversal is reproducible through both public import APIs on main (63101e6): the synthetic outside row reaches SQLite. The repaired candidate rejects parent, absolute, and legacy-parent paths, and the destination retains its preexisting row after rejection. It also preserves ., ./, empty roots, and literal root/shard whitespace; decoy siblings cannot supply rows. The added regression matrix fails on the original PR head for all six affected path cases in each API and passes after the repair.

Validation on macOS arm64, Go 1.27.1:

  • GOWORK=off go test -count=1 ./...: all 17 packages pass.
  • GOMAXPROCS=2 make check: module tidiness, formatting, vet, deadcode, govulncheck, unit tests, race tests, and release-dispatch tests pass.
  • actionlint: pass.
  • Independent Codex branch autoreview against origin/main, through P2: scoped-clean, no actionable findings.
  • Built crawlctl and drove --help, --version, status --json, and run proof --json with a temporary config and /usr/bin/true job. Truncated history is repaired on append; valid unterminated history is preserved.
  • Exact-head Linux/Windows CI: success — https://github.com/openclaw/crawlkit/actions/runs/33983659904

The mandatory review scan initially stalled traversing the host's system temp directory; a fresh private task temp directory allowed the same scanner and isolated review to complete. Govulncheck reports no affected symbols or imported packages; its module-only advisory concerns the unused, unmaintained x/crypto/openpgp package (GO-2026-5932).

The standalone integration harness used GOWORK=off go run with real gzip JSONL files and SQLite. Its output on the repaired candidate:

full nested         accepted rows=intended
full dot            accepted rows=intended
full dot-slash      accepted rows=intended
full empty-root     accepted rows=intended
full root-space     accepted rows=intended
full shard-space    accepted rows=intended
full parent         rejected rows=existing
full absolute       rejected rows=existing
full legacy-parent  rejected rows=existing
full symlink        accepted rows=foreign
incremental nested         accepted rows=intended
incremental dot            accepted rows=intended
incremental dot-slash      accepted rows=intended
incremental empty-root     accepted rows=intended
incremental root-space     accepted rows=intended
incremental shard-space    accepted rows=intended
incremental parent         rejected rows=existing
incremental absolute       rejected rows=existing
incremental legacy-parent  rejected rows=existing
incremental symlink        accepted rows=foreign

Symlink following is unchanged from main and was exercised explicitly. This is a lexical path guard, not symlink-safe confinement; the README requires a trusted filesystem tree.

Standalone synthetic integration program

Save as snapshot-proof.go outside the module and run GOWORK=off go run /path/to/snapshot-proof.go from this checkout. It creates and removes its own temporary data.

package main

import (
 "compress/gzip"
 "context"
 "encoding/json"
 "fmt"
 "os"
 "path/filepath"

 "github.com/openclaw/crawlkit/snapshot"
 "github.com/openclaw/crawlkit/store"
)

func must(err error) { if err != nil { panic(err) } }
func shard(path, id string) {
 must(os.MkdirAll(filepath.Dir(path), 0700))
 f, err := os.Create(path); must(err)
 gz := gzip.NewWriter(f)
 must(json.NewEncoder(gz).Encode(map[string]any{"id":id}))
 must(gz.Close()); must(f.Close())
}
func main() {
 ctx := context.Background()
 base, err := os.MkdirTemp("", "crawlkit-synthetic-proof-"); must(err); defer os.RemoveAll(base)
 cwd, err := os.Getwd(); must(err); defer os.Chdir(cwd)
 for _, mode := range []string{"full", "incremental"} {
  for _, name := range []string{"nested", "dot", "dot-slash", "empty-root", "root-space", "shard-space", "parent", "absolute", "legacy-parent", "symlink"} {
   root := filepath.Join(base, mode, name, "archive")
   rel := "tables/things/000001.jsonl.gz"
   if name == "root-space" { root += " " }
   if name == "shard-space" { rel = " tables/things/000001.jsonl.gz " }
   shard(filepath.Join(root, rel), "intended")
   if name == "root-space" { shard(filepath.Join(filepath.Dir(root), "archive", rel), "foreign") }
   if name == "shard-space" { shard(filepath.Join(root, "tables/things/000001.jsonl.gz"), "foreign") }
   outside := filepath.Join(filepath.Dir(root), "outside.jsonl.gz")
   shard(outside, "foreign")
   if name == "parent" || name == "legacy-parent" { rel = "../outside.jsonl.gz" }
   if name == "absolute" { rel = outside }
   if name == "symlink" { rel = "linked.jsonl.gz"; must(os.Symlink(outside, filepath.Join(root, rel))) }
   table := snapshot.TableManifest{Name:"things", Files:[]string{rel}, Columns:[]string{"id"}, Rows:1}
   if name == "legacy-parent" { table.Files=nil; table.File=rel }
   m := snapshot.Manifest{Version:1, Tables:[]snapshot.TableManifest{table}}
   must(snapshot.WriteManifest(root,m))
   must(os.Chdir(root))
   if name == "dot" { root="." }; if name=="dot-slash" { root="./" }; if name=="empty-root" { root="" }
   db, err := store.Open(ctx,store.Options{Path:":memory:", Schema:"create table things(id text primary key)"}); must(err)
   _, err = db.DB().ExecContext(ctx,"insert into things values ('existing')"); must(err)
   if mode == "full" { _, err=snapshot.Import(ctx,snapshot.ImportOptions{DB:db.DB(),RootDir:root}) } else { _,_,err=snapshot.ImportIncremental(ctx,snapshot.IncrementalImportOptions{DB:db.DB(),RootDir:root,Previous:snapshot.Manifest{Version:1},Current:m}) }
   outcome := "accepted"; if err != nil { outcome="rejected" }
   var rows string
   must(db.DB().QueryRowContext(ctx,"select coalesce(group_concat(id, ','), '') from things").Scan(&rows))
   fmt.Printf("%s %-14s %s rows=%s\n", mode,name,outcome,rows)
   must(db.Close())
   must(os.Chdir(cwd))
  }
 }
}

The contributor's original commit is retained, and the changelog credits @SebTardif. Recommended squash title: fix(snapshot): reject manifest path traversal. No merge or release has been performed.

@steipete
steipete merged commit 5595076 into openclaw:main Sep 5, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. merge-risk: 🚨 security-boundary 🚨 Merging this PR could weaken sandboxing, authorization, credentials, or sensitive data. P2 Normal priority bug or improvement with limited blast radius. rating: 🦐 gold shrimp Decent PR readiness signal, but merge confidence is limited. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants