instanceexec: bind launch file host to the instance ID (plan 2, task 2 fix round 1)
Add validation to ensure the launch file's Host matches the instance being executed. This prevents a tenant from rewriting their launch file to execute another host's binary. Also wrap symlink/escape errors with both sentinels for error inspection. - Add InstanceID check after ValidHostShape validation - Update execLaunch doc comment to explain the host binding constraint - Wrap instancefs errors with both errSymlinkOrEscape and original error - Add TestExecLaunchRefusesAHostThatIsNotThisInstance covering both short and long hostname cases
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YZ1ZvWj7naf73GjTX8MBv5
2 files changed,
+46
−2
internal/instanceexec/exec.go+6 −2internal/instanceexec/exec_test.go+40 −0
diff --git a/internal/instanceexec/exec.go b/internal/instanceexec/exec.go| index dd89bdf85..1778ec16e 100644 |
| --- a/internal/instanceexec/exec.go |
| +++ b/internal/instanceexec/exec.go |
| @@ -65,14 +65,15 @@ func RunExec(args []string) { |
| // directory is the instance's own, so the instance can rewrite it. What |
| // the checks guarantee is that whatever is executed is directly under |
| // LiveDir (the platform's live links, root-written) and named for the host |
| -// in the file, so a rewritten file can execute only what the tenant could |
| +// in the file. The file's host must also match the instance the unit was |
| +// started for, so a rewritten file can execute only what the tenant could |
| // execute anyway: its own binary with an argv and env it chose. |
| func execLaunch(dataDir string, role instancectl.Role, iid string, execFn func(argv0 string, argv, env []string) error) error { |
| dir := filepath.Join(dataDir, "instances", iid) |
| raw, err := instancefs.ReadBeneath(dir, instancectl.LaunchFileName(role)) |
| if err != nil { |
| if errors.Is(err, instancefs.ErrSymlink) || errors.Is(err, instancefs.ErrEscapes) { |
| - return fmt.Errorf("%w: %v", errSymlinkOrEscape, err) |
| + return fmt.Errorf("%w: %w", errSymlinkOrEscape, err) |
| } |
| return err |
| } |
| @@ -86,6 +87,9 @@ func execLaunch(dataDir string, role instancectl.Role, iid string, execFn func(a |
| if !provision.ValidHostShape(l.Host) { |
| return fmt.Errorf("launch file host %q is not a host", l.Host) |
| } |
| + if provision.InstanceID(l.Host) != iid { |
| + return fmt.Errorf("launch file host %q is not instance %s", l.Host, iid) |
| + } |
| if len(l.Argv) == 0 { |
| return errors.New("launch file has no argv") |
| } |
diff --git a/internal/instanceexec/exec_test.go b/internal/instanceexec/exec_test.go| index 454c97866..75250e002 100644 |
| --- a/internal/instanceexec/exec_test.go |
| +++ b/internal/instanceexec/exec_test.go |
| @@ -10,6 +10,7 @@ import ( |
| "testing" |
| "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/provision" |
| ) |
| func writeLaunch(t *testing.T, dir string, role instancectl.Role, l instancectl.Launch) { |
| @@ -116,3 +117,42 @@ func TestExecLaunchRefusesASymlinkedLaunchFile(t *testing.T) { |
| t.Errorf("symlinked launch file: err = %v, calls = %d", err, c.calls) |
| } |
| } |
| + |
| +func TestExecLaunchRefusesAHostThatIsNotThisInstance(t *testing.T) { |
| + // Case 1: launch file in instances/a.example.com/ with Host: b.example.com |
| + data := t.TempDir() |
| + iidA := "a.example.com" |
| + dirA := filepath.Join(data, "instances", iidA) |
| + writeLaunch(t, dirA, instancectl.RoleRun, instancectl.Launch{ |
| + Schema: 1, Host: "b.example.com", |
| + Argv: []string{"/opt/carlos/live/b.example.com"}, |
| + }) |
| + var c captured |
| + if err := execLaunch(data, instancectl.RoleRun, iidA, c.exec); err == nil || c.calls != 0 { |
| + t.Errorf("host mismatch (short): err = %v, calls = %d; want refusal", err, c.calls) |
| + } |
| + |
| + // Case 2: long hostname (60 bytes), directory name is provision.InstanceID(long), Host is long |
| + long := "a" + "b" + "c" + "d" + "e" + "f" + "g" + "h" + "i" + "j" + |
| + "k" + "l" + "m" + "n" + "o" + "p" + "q" + "r" + "s" + "t" + |
| + "u" + "v" + "w" + "x" + "y" + "z" + "0" + "1" + "2" + "3" + |
| + "4" + "5" + "6" + "7" + "8" + "9" + "a" + "b" + "c" + "d" + |
| + "e" + "f" + "g" + "h" + "i" + "j" + "k" + "l" + "m" + "n" + |
| + "o" + "p" + "q" + "r" + "s" + "t" + "u" + "v" + "w" + "x" |
| + if len(long) != 60 { |
| + t.Fatalf("long host is %d bytes, want 60", len(long)) |
| + } |
| + iidLong := provision.InstanceID(long) |
| + dirLong := filepath.Join(data, "instances", iidLong) |
| + writeLaunch(t, dirLong, instancectl.RoleRun, instancectl.Launch{ |
| + Schema: 1, Host: long, |
| + Argv: []string{"/opt/carlos/live/" + long}, |
| + }) |
| + c2 := captured{} |
| + if err := execLaunch(data, instancectl.RoleRun, iidLong, c2.exec); err != nil { |
| + t.Errorf("host match (long): err = %v; want no error", err) |
| + } |
| + if c2.calls != 1 { |
| + t.Errorf("host match (long): calls = %d; want 1", c2.calls) |
| + } |
| +} |