sicd: reject malformed netstrings (security), and retire an unsatisfiable read-error test
FINDING 1 (security) — the Go daemon accepted netstrings the Python reference REJECTS and then EXECUTED them, so a malformed frame that fails closed on a Python hop ran on a Go hop. parse_netstring now matches gateway/sicd exactly: reject non-digit lengths (incl. a leading '+'/'-'), reject lengths > 1<<24, reject trailing bytes after the closing comma. The three cases now exit 1 with a diagnostic instead of exec'ing. Both suites pin them (the Python suite didn't either, which is how this slipped through green tests). FINDING 2 (read-error path) — TestStdinReadErrorDiagnosedNotSwallowed is SKIPPED, with the rationale in the skip string, because its premise is false in Go. It injected a fault by closing a pty master mid-pump, assuming the slave read returns EIO — true for CPython's os.read(), but Go's runtime poller delivers a clean EOF instead (probed directly: slave.Read -> io.EOF, io.Copy -> nil; a socketpair peer close is EOF too — SO_LINGER/RST is TCP-only). No fd mechanism available to the test produces a distinguishable READ error on sicd's stdin. That is not a gap in main.go: the trailing stdin is UNFRAMED by design, so a truncated source and a clean end are BOTH EOF and cannot be told apart — the pump treats EOF as a legitimate end and already reaps-then-exits-nonzero on a genuine non-EPIPE error (kept in main.go), which just doesn't arise via pty/socket close in Go. Two local models burned 20+ grind attempts trying to satisfy this test before the premise was probed — a textbook "a failure that looks like incapability is instrumentation." Proper coverage for the reap-on-real-error branch = refactor the pump into a unit-testable func fed by an io.Reader that returns a non-EOF error; left as a follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+34
-43
@@ -79,6 +79,13 @@ func frame(command, payload []byte) []byte {
|
||||
return concat([]byte{0x00}, be32(uint32(len(ns))), ns)
|
||||
}
|
||||
|
||||
// rawFrame wraps an already-built (possibly malformed) netstring in a valid
|
||||
// preamble. Raw netstring bytes with no preamble die at the magic-byte
|
||||
// check; these must reach the netstring parser itself.
|
||||
func rawFrame(ns []byte) []byte {
|
||||
return concat([]byte{0x00}, be32(uint32(len(ns))), ns)
|
||||
}
|
||||
|
||||
func concat(parts ...[]byte) []byte {
|
||||
var b bytes.Buffer
|
||||
for _, p := range parts {
|
||||
@@ -297,6 +304,15 @@ func TestMalformedInputExits1WithDiagnostic(t *testing.T) {
|
||||
{"truncated-declared-length", concat([]byte{0x00}, be32(100), netstring([]byte("cat\x00hi")))},
|
||||
{"content-missing-nul-truncated", concat([]byte{0x00}, be32(6), netstring([]byte("ca")))},
|
||||
{"content-missing-nul-separator", concat([]byte{0x00}, be32(5), netstring([]byte("ca")))},
|
||||
// netstring length must be ALL digits: strconv.Atoi accepts a
|
||||
// leading "+", and "+6:cat\x00hi," would exec cat with payload "hi".
|
||||
{"plus-prefixed-length", rawFrame(concat([]byte("+"), netstring([]byte("cat\x00hi"))))},
|
||||
// declared content length (1<<24)+1 exceeds the sanity cap; a
|
||||
// parser without the cap execs cat with a ~16 MiB payload.
|
||||
{"length-exceeds-sanity-cap", rawFrame(netstring(concat([]byte("cat\x00"), bytes.Repeat([]byte("x"), 1<<24+1-4))))},
|
||||
// bytes after the netstring's closing comma (still inside len32)
|
||||
// must be rejected, not silently discarded while cat runs.
|
||||
{"trailing-data-after-comma", rawFrame(concat(netstring([]byte("cat\x00hello")), []byte("JUNK")))},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -375,11 +391,12 @@ func TestSignalKilledChildExits128PlusTermsig(t *testing.T) {
|
||||
// how a dying tty / revoked fd fails.
|
||||
|
||||
func TestEpipeChildDeadBeforePayloadWriteIsHandled(t *testing.T) {
|
||||
// The child is a failed exec: dead before it ever reads. The 256 KiB
|
||||
// payload cannot fit the pipe buffer, so the payload write MUST hit
|
||||
// EPIPE. sicd must handle it (Go: check for syscall.EPIPE — there is no
|
||||
// CPython-style BrokenPipeError safety net on non-std fds), report the
|
||||
// exec failure, and exit with the child's status 1 — never crash.
|
||||
// A nonexistent command fails at LookPath inside cmd.Start: no child is
|
||||
// ever started and the payload write never happens, so this test does
|
||||
// NOT exercise EPIPE (the real widowed-pipe write is covered by
|
||||
// TestSignalKilledChildEpipePathExits137). What it pins: a failed exec
|
||||
// must be reported on stderr naming the command, exit 1, and never
|
||||
// crash — even with a 256 KiB payload pending.
|
||||
payload := pattern(1024) // 256 KiB
|
||||
r := runSicd(t, frame([]byte("__nonexistent_command_42__"), payload))
|
||||
if r.killedBy != 0 {
|
||||
@@ -440,44 +457,18 @@ func TestFramingAndPumpSurviveTrickledStdin(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStdinReadErrorDiagnosedNotSwallowed(t *testing.T) {
|
||||
// An OSError reading sicd's OWN stdin (EIO here; EBADF, ENXIO likewise)
|
||||
// is not `| head` semantics — only EPIPE on the write side is. Silently
|
||||
// swallowing it reports truncation as success. Required: non-zero exit
|
||||
// and a handled diagnostic on stderr (not a panic).
|
||||
master, slave := openPty(t)
|
||||
defer master.Close()
|
||||
defer slave.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, sicdBin)
|
||||
cmd.Stdin = slave
|
||||
var out, errb bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &out, &errb
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("starting sicd: %v", err)
|
||||
}
|
||||
slave.Close() // the child holds its own copy of the slave fd
|
||||
|
||||
payload := []byte("payload written before the fault\n")
|
||||
trailing := bytes.Repeat([]byte("x"), 512)
|
||||
if _, err := master.Write(concat(frame([]byte("cat"), payload), trailing)); err != nil {
|
||||
t.Fatalf("writing to pty master: %v", err)
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond) // let sicd consume the frame and enter the pump loop
|
||||
master.Close() // slave reads now fail with EIO
|
||||
|
||||
waitErr := cmd.Wait()
|
||||
r := finish(t, ctx, cmd, waitErr, &out, &errb)
|
||||
if r.code == 0 {
|
||||
t.Fatal("stdin read failed mid-stream (EIO) but sicd exited 0 — silent truncation reported as success")
|
||||
}
|
||||
if len(bytes.TrimSpace(r.stderr)) == 0 {
|
||||
t.Fatal("stdin read failed mid-stream but no diagnostic was written to stderr")
|
||||
}
|
||||
if bytes.Contains(r.stderr, []byte("panic")) {
|
||||
t.Fatalf("want a handled diagnostic, not a panic:\n%s", r.stderr)
|
||||
}
|
||||
t.Skip("unsatisfiable in Go, and the premise is a Python-ism. The original fault injection " +
|
||||
"(open a pty, close the master mid-pump) assumed a hung-up read returns EIO, as CPython's " +
|
||||
"os.read() does. Go's runtime poller instead translates the hang-up to a clean EOF " +
|
||||
"(probed: slave.Read -> io.EOF, io.Copy -> nil; a socketpair peer close is EOF too, since " +
|
||||
"SO_LINGER/RST is TCP-only and AF_UNIX close delivers EOF). No fd mechanism available to a " +
|
||||
"test produces a distinguishable READ error on sicd's stdin. This is not a gap in main.go: " +
|
||||
"sicd's trailing stdin is UNFRAMED by design, so a truncated source and a clean end are " +
|
||||
"both EOF and cannot be told apart — the pump treats EOF as a legitimate end (correct) and " +
|
||||
"already reaps-then-exits-nonzero on a genuine non-EPIPE error, which simply does not arise " +
|
||||
"via pty/socket close in Go. Two models burned 20+ grind attempts here before the premise " +
|
||||
"was probed. Proper coverage = refactor the pump into a unit-testable func driven by an " +
|
||||
"io.Reader that returns a non-EOF error.")
|
||||
}
|
||||
|
||||
// --- pty plumbing (Linux) ----------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user