carlos / platform Public

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
Paul Campbell pushed by paul@keymail.dev acaa97cfd83dbf53991ba5e8c844675a588815d7 parent cd0d0df
2 files changed, +46 −2
  • internal/instanceexec/exec.go +6 −2
  • internal/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)
+ }
+}