Resolve conflict: keep OFFSEC-010 collectCPConfigFiles with ce542cb26 nil-return fix
Some checks failed
CI / all-required (pull_request) Blocked by required conditions
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 9s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Successful in 1m20s
publish-runtime-autobump / pr-validate (pull_request) Successful in 44s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m32s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m50s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m31s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m57s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m0s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 20s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m24s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
CI / Detect changes (pull_request) Successful in 34s
Harness Replays / detect-changes (pull_request) Successful in 15s
E2E API Smoke Test / detect-changes (pull_request) Successful in 36s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 33s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 12s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 26s
qa-review / approved (pull_request) Successful in 13s
security-review / approved (pull_request) Successful in 12s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 22s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 48s
audit-force-merge / audit (pull_request) Has been skipped
sop-checklist / all-items-acked (pull_request) Successful in 11s
sop-tier-check / tier-check (pull_request) Successful in 12s
gate-check-v3 / gate-check (pull_request) Failing after 14s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m8s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 9s
Harness Replays / Harness Replays (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 13s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 18s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6m33s
CI / Platform (Go) (pull_request) Failing after 2m28s
CI / Canvas (Next.js) (pull_request) Failing after 8m43s
CI / Python Lint & Test (pull_request) Failing after 6m36s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m56s
Some checks failed
CI / all-required (pull_request) Blocked by required conditions
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 9s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Successful in 1m20s
publish-runtime-autobump / pr-validate (pull_request) Successful in 44s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m32s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m50s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m31s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m57s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 2m0s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 20s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m24s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
CI / Detect changes (pull_request) Successful in 34s
Harness Replays / detect-changes (pull_request) Successful in 15s
E2E API Smoke Test / detect-changes (pull_request) Successful in 36s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 33s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 12s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 26s
qa-review / approved (pull_request) Successful in 13s
security-review / approved (pull_request) Successful in 12s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 22s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 48s
audit-force-merge / audit (pull_request) Has been skipped
sop-checklist / all-items-acked (pull_request) Successful in 11s
sop-tier-check / tier-check (pull_request) Successful in 12s
gate-check-v3 / gate-check (pull_request) Failing after 14s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m8s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 9s
Harness Replays / Harness Replays (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 13s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 18s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 6m33s
CI / Platform (Go) (pull_request) Failing after 2m28s
CI / Canvas (Next.js) (pull_request) Failing after 8m43s
CI / Python Lint & Test (pull_request) Failing after 6m36s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m56s
This commit is contained in:
parent
1df0e378b6
commit
69d5eb4cd2
@ -4,12 +4,14 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -237,6 +239,81 @@ func (p *CPProvisioner) Start(ctx context.Context, cfg WorkspaceConfig) (string,
|
||||
return result.InstanceID, nil
|
||||
}
|
||||
|
||||
const cpConfigFilesMaxBytes = 12 << 10
|
||||
|
||||
func collectCPConfigFiles(cfg WorkspaceConfig) (map[string]string, error) {
|
||||
files := make(map[string]string)
|
||||
total := 0
|
||||
addFile := func(name string, data []byte) error {
|
||||
name = filepath.ToSlash(filepath.Clean(name))
|
||||
if name == "." || strings.HasPrefix(name, "../") || strings.HasPrefix(name, "/") || strings.Contains(name, "/../") {
|
||||
return fmt.Errorf("invalid config file path %q", name)
|
||||
}
|
||||
total += len(data)
|
||||
if total > cpConfigFilesMaxBytes {
|
||||
return fmt.Errorf("config files exceed %d bytes", cpConfigFilesMaxBytes)
|
||||
}
|
||||
files[name] = base64.StdEncoding.EncodeToString(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.TemplatePath != "" {
|
||||
// Reject symlinks on the root itself — WalkDir follows symlinks,
|
||||
// so a symlink TemplatePath that escapes the intended root directory
|
||||
// would bypass the subsequent path-relativization checks below.
|
||||
rootInfo, err := os.Lstat(cfg.TemplatePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("collectCPConfigFiles: lstat template path: %w", err)
|
||||
}
|
||||
if rootInfo.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, fmt.Errorf("collectCPConfigFiles: template path must not be a symlink")
|
||||
}
|
||||
err = filepath.WalkDir(cfg.TemplatePath, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
// Skip symlinks — WalkDir follows them by default, which means
|
||||
// a symlink inside the template dir pointing to /etc/passwd
|
||||
// would be traversed even though the resulting relative-path
|
||||
// check would correctly reject it. Defense-in-depth: don't
|
||||
// follow symlinks at all. (OFFSEC-010)
|
||||
if d.Type()&os.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(cfg.TemplatePath, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return addFile(rel, data)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for name, data := range cfg.ConfigFiles {
|
||||
if err := addFile(name, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
// Stop terminates the workspace's EC2 instance via the control plane.
|
||||
//
|
||||
// Looks up the actual EC2 instance_id from the workspaces table before
|
||||
|
||||
@ -842,3 +842,67 @@ func TestIsRunning_EmptyInstanceIDReturnsFalse(t *testing.T) {
|
||||
t.Errorf("IsRunning with empty instance_id should return running=false, got true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectCPConfigFiles_SkipsSymlinks — WalkDir follows symlinks by default,
|
||||
// but collectCPConfigFiles must skip them so a symlink inside a template dir
|
||||
// pointing outside (e.g. ln -s /etc snapshot) cannot be traversed.
|
||||
// Verifies OFFSEC-010 defense-in-depth fix. (OFFSEC-010)
|
||||
func TestCollectCPConfigFiles_SkipsSymlinks(t *testing.T) {
|
||||
tmpl := t.TempDir()
|
||||
// Write a real file that should be included.
|
||||
if err := os.WriteFile(filepath.Join(tmpl, "config.yaml"), []byte("name: real\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Create a subdir with a file that will be symlinked-outside.
|
||||
sensitiveDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(sensitiveDir, "secret.txt"), []byte("SENSITIVE\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Symlink inside template dir pointing to outside path.
|
||||
symlinkPath := filepath.Join(tmpl, "snapshot")
|
||||
if err := os.Symlink(sensitiveDir, symlinkPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
files, err := collectCPConfigFiles(WorkspaceConfig{TemplatePath: tmpl})
|
||||
if err != nil {
|
||||
t.Fatalf("collectCPConfigFiles: %v", err)
|
||||
}
|
||||
if files == nil {
|
||||
t.Fatal("files should not be nil")
|
||||
}
|
||||
// config.yaml must be present.
|
||||
if _, ok := files["config.yaml"]; !ok {
|
||||
t.Errorf("config.yaml missing from files")
|
||||
}
|
||||
// The symlinked path must NOT be included (even though WalkDir would
|
||||
// traverse it, the d.Type()&os.ModeSymlink guard skips the entry).
|
||||
for k := range files {
|
||||
if strings.Contains(k, "snapshot") || strings.Contains(k, "secret") {
|
||||
t.Errorf("symlink path %q should not be in files — OFFSEC-010 regression", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectCPConfigFiles_RejectsRootSymlink — if cfg.TemplatePath itself is
|
||||
// a symlink, WalkDir would follow it to an arbitrary directory, bypassing the
|
||||
// cfg.TemplatePath boundary. The function must reject this case explicitly.
|
||||
// (OFFSEC-010)
|
||||
func TestCollectCPConfigFiles_RejectsRootSymlink(t *testing.T) {
|
||||
real := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(real, "config.yaml"), []byte("name: real\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(t.TempDir(), "template-link")
|
||||
if err := os.Symlink(real, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := collectCPConfigFiles(WorkspaceConfig{TemplatePath: link})
|
||||
if err == nil {
|
||||
t.Error("collectCPConfigFiles with symlink TemplatePath should return error")
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "symlink") {
|
||||
t.Errorf("expected symlink-related error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user