Compare commits

...

1 Commits

Author SHA1 Message Date
core-be c51e0013ac fix(provisioner): skip symlinks in collectCPConfigFiles (OFFSEC-010)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 26s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m18s
CI / Detect changes (pull_request) Successful in 1m27s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m16s
Harness Replays / detect-changes (pull_request) Successful in 23s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 1m4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 26s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m2s
qa-review / approved (pull_request) Failing after 28s
security-review / approved (pull_request) Failing after 30s
sop-checklist / na-declarations (pull_request) awaiting /sop-n/a declaration for: qa-review, security-review
gate-check-v3 / gate-check (pull_request) Successful in 40s
sop-tier-check / tier-check (pull_request) Successful in 22s
sop-checklist / all-items-acked (pull_request) Successful in 24s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 56s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m29s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 10s
Harness Replays / Harness Replays (pull_request) Successful in 9s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m49s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m37s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 6s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 4m8s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 8m53s
audit-force-merge / audit (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Successful in 13m52s
CI / Canvas (Next.js) (pull_request) Successful in 14m8s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 8s
OFFSEC-010: filepath.WalkDir descends into symlinks by default. A
malicious template could contain `ln -s /etc/passwd snapshot`, and
WalkDir would traverse it — the existing `../`/absolute-path guards in
addFile don't catch symlinks because the symlink path itself contains
no ".." or leading "/".

Fix: check d.Type()&os.ModeSymlink in the WalkDir callback and return
nil to skip symlinks before they are descended. d.Type() is a zero-cost
DirEntry attribute (no extra syscall).

Also add TestCollectCPConfigFiles_SkipsSymlinks regression test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 17:53:48 +00:00
2 changed files with 59 additions and 0 deletions
@@ -269,6 +269,14 @@ func collectCPConfigFiles(cfg WorkspaceConfig) (map[string]string, error) {
if walkErr != nil {
return walkErr
}
// OFFSEC-010: skip symlinks — WalkDir descends into them by default,
// and a malicious template could symlink to sensitive files outside
// the intended template root (e.g. /etc/passwd). The path checks
// in addFile guard against ../-escaped paths, but symlinks can
// cross the root without any ".." component, so we must skip them.
if d.Type()&os.ModeSymlink != 0 {
return nil
}
if d.IsDir() {
return nil
}
@@ -263,6 +263,57 @@ func TestStart_SendsTemplateAndGeneratedConfigFiles(t *testing.T) {
}
}
// TestCollectCPConfigFiles_SkipsSymlinks verifies OFFSEC-010: WalkDir descends
// into symlinks by default, but we skip them so a template containing
// ln -s /etc/passwd myapp/snapshot cannot exfiltrate files outside the root.
func TestCollectCPConfigFiles_SkipsSymlinks(t *testing.T) {
tmpl := t.TempDir()
// Regular file inside the template — should be included.
if err := os.WriteFile(filepath.Join(tmpl, "config.yaml"), []byte("name: template\n"), 0o600); err != nil {
t.Fatal(err)
}
// Symlink pointing outside the template root — must NOT be followed.
sensitive := t.TempDir()
sensitiveFile := filepath.Join(sensitive, "secret.txt")
if err := os.WriteFile(sensitiveFile, []byte("ssh keys!"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(sensitiveFile, filepath.Join(tmpl, "snapshot")); err != nil {
t.Fatal(err)
}
var body cpProvisionRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode request: %v", err)
}
w.WriteHeader(http.StatusCreated)
_, _ = io.WriteString(w, `{"instance_id":"i-abc123","state":"pending"}`)
}))
defer srv.Close()
p := &CPProvisioner{baseURL: srv.URL, orgID: "org-1", httpClient: srv.Client()}
_, err := p.Start(context.Background(), WorkspaceConfig{
WorkspaceID: "ws-1",
Runtime: "claude-code",
Tier: 4,
PlatformURL: "http://tenant",
TemplatePath: tmpl,
})
if err != nil {
t.Fatalf("Start: %v", err)
}
// Only the regular file should be included; the symlink target must not appear.
wantConfig := base64.StdEncoding.EncodeToString([]byte("name: template\n"))
if got := body.ConfigFiles["config.yaml"]; got != wantConfig {
t.Errorf("config.yaml payload = %q, want %q", got, wantConfig)
}
if _, ok := body.ConfigFiles["snapshot"]; ok {
t.Error("snapshot symlink should not appear in config_files (OFFSEC-010)")
}
}
// TestStart_Non201ReturnsStructuredError — when CP returns 401 with a
// structured {"error":"..."} body, Start surfaces that error message.
// Verifies the defense against log-leaking raw upstream bodies.