375 lines
13 KiB
Go
375 lines
13 KiB
Go
package main
|
|
|
|
// Gate: backup_fetch delivers the right bytes to the right place, and refuses
|
|
// everything else. Each case is one way a backup could silently be wrong or one
|
|
// way the host could point this machine somewhere it has no business going.
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// drive runs one backup_fetch job through the real mgmt frame path and returns
|
|
// (stdout, stderr, exit) the way the host's management worker collects them.
|
|
func drive(t *testing.T, backupRoot string, req backupFetchReq) (string, string, int) {
|
|
t.Helper()
|
|
arg, _ := json.Marshal(req)
|
|
up := websocket.Upgrader{}
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ws, err := up.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer ws.Close()
|
|
handleMgmtConn(ws, "", "", backupRoot)
|
|
}))
|
|
defer srv.Close()
|
|
c, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
defer c.Close()
|
|
job, _ := json.Marshal(mgmtJob{Op: "backup_fetch", Args: []string{string(arg)}})
|
|
if err := c.WriteMessage(websocket.BinaryMessage, append([]byte("C"), job...)); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
var out, errOut string
|
|
for {
|
|
_, data, err := c.ReadMessage()
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
switch data[0] {
|
|
case 'O':
|
|
out += string(data[1:])
|
|
case 'R':
|
|
errOut += string(data[1:])
|
|
case 'X':
|
|
var code int
|
|
fmt.Sscanf(string(data[1:]), "%d", &code)
|
|
return out, errOut, code
|
|
}
|
|
}
|
|
}
|
|
|
|
func artifactServer(t *testing.T, body []byte) (*httptest.Server, string) {
|
|
t.Helper()
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write(body)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
sum := sha256.Sum256(body)
|
|
return srv, hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// The SSRF wall refuses httptest's 127.0.0.1 URLs, which is exactly right and
|
|
// also makes a happy-path test impossible against a local server. So the happy
|
|
// path drives runBackupFetch directly (the same function the goroutine runs),
|
|
// and the wall gets its own tests through the full frame path.
|
|
func TestBackupFetchWritesVerifiedArtifact(t *testing.T) {
|
|
allowLoopbackFetch(t)
|
|
root := t.TempDir()
|
|
body := []byte(strings.Repeat("the user's backup, 2026-08-18. ", 5000))
|
|
srv, want := artifactServer(t, body)
|
|
http.DefaultTransport.(*http.Transport).TLSClientConfig = nil // keep other tests clean
|
|
|
|
dest := filepath.Join(root, "privatae", "tenant-42", "backup.tar.zst.age")
|
|
job := &backupJob{ID: "t1", SHA256: want, Want: int64(len(body)), State: "running"}
|
|
// srv.Client() trusts the test cert; runBackupFetch builds its own client, so
|
|
// point it at the server through the same transport by installing the cert.
|
|
oldTransport := http.DefaultTransport
|
|
http.DefaultTransport = srv.Client().Transport
|
|
defer func() { http.DefaultTransport = oldTransport }()
|
|
|
|
runBackupFetch(job, srv.URL+"/artifact", dest, int64(len(body))+1)
|
|
if job.State != "done" {
|
|
t.Fatalf("state=%s error=%s", job.State, job.Error)
|
|
}
|
|
got, err := os.ReadFile(dest)
|
|
if err != nil {
|
|
t.Fatalf("destination not written: %v", err)
|
|
}
|
|
if string(got) != string(body) {
|
|
t.Fatal("delivered bytes differ from the source")
|
|
}
|
|
if job.Got != int64(len(body)) {
|
|
t.Fatalf("progress says %d bytes, file has %d", job.Got, len(body))
|
|
}
|
|
fi, _ := os.Stat(dest)
|
|
if fi.Mode().Perm() != 0o600 {
|
|
t.Fatalf("artifact mode is %v, want 0600 — a backup is not world-readable", fi.Mode().Perm())
|
|
}
|
|
// No .part left behind.
|
|
entries, _ := os.ReadDir(filepath.Dir(dest))
|
|
for _, e := range entries {
|
|
if strings.HasSuffix(e.Name(), ".part") {
|
|
t.Fatalf("temp file survived: %s", e.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBackupFetchRefusesWrongDigest(t *testing.T) {
|
|
allowLoopbackFetch(t)
|
|
root := t.TempDir()
|
|
body := []byte("a backup that got tampered with")
|
|
srv, _ := artifactServer(t, body)
|
|
oldTransport := http.DefaultTransport
|
|
http.DefaultTransport = srv.Client().Transport
|
|
defer func() { http.DefaultTransport = oldTransport }()
|
|
|
|
dest := filepath.Join(root, "x.bin")
|
|
wrong := strings.Repeat("00", 32)
|
|
job := &backupJob{ID: "t2", SHA256: wrong, Want: int64(len(body)), State: "running"}
|
|
runBackupFetch(job, srv.URL+"/a", dest, 1<<20)
|
|
if job.State != "failed" {
|
|
t.Fatalf("a wrong digest must fail, got %s", job.State)
|
|
}
|
|
if !strings.Contains(job.Error, "sha256 mismatch") {
|
|
t.Fatalf("error should name the mismatch, got %q", job.Error)
|
|
}
|
|
if _, err := os.Stat(dest); !os.IsNotExist(err) {
|
|
t.Fatal("a failed fetch must leave NOTHING at the destination path")
|
|
}
|
|
}
|
|
|
|
func TestBackupFetchRefusesOversizeBody(t *testing.T) {
|
|
allowLoopbackFetch(t)
|
|
root := t.TempDir()
|
|
body := []byte(strings.Repeat("x", 4096))
|
|
srv, want := artifactServer(t, body)
|
|
oldTransport := http.DefaultTransport
|
|
http.DefaultTransport = srv.Client().Transport
|
|
defer func() { http.DefaultTransport = oldTransport }()
|
|
|
|
dest := filepath.Join(root, "big.bin")
|
|
job := &backupJob{ID: "t3", SHA256: want, Want: 100, State: "running"}
|
|
runBackupFetch(job, srv.URL+"/a", dest, 100) // declared 100, serves 4096
|
|
if job.State != "failed" || !strings.Contains(job.Error, "larger than the declared") {
|
|
t.Fatalf("oversize body must stop the stream, got state=%s err=%q", job.State, job.Error)
|
|
}
|
|
if _, err := os.Stat(dest); !os.IsNotExist(err) {
|
|
t.Fatal("nothing should be published")
|
|
}
|
|
}
|
|
|
|
func TestBackupFetchRefusesHTTPError(t *testing.T) {
|
|
allowLoopbackFetch(t)
|
|
root := t.TempDir()
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "expired", http.StatusForbidden)
|
|
}))
|
|
defer srv.Close()
|
|
oldTransport := http.DefaultTransport
|
|
http.DefaultTransport = srv.Client().Transport
|
|
defer func() { http.DefaultTransport = oldTransport }()
|
|
|
|
job := &backupJob{ID: "t4", SHA256: strings.Repeat("ab", 32), State: "running"}
|
|
runBackupFetch(job, srv.URL+"/a", filepath.Join(root, "e.bin"), 1<<20)
|
|
if job.State != "failed" || !strings.Contains(job.Error, "HTTP 403") {
|
|
t.Fatalf("an expired signed URL must surface its status, got %q", job.Error)
|
|
}
|
|
}
|
|
|
|
// ── the SSRF wall, through the full frame path ───────────────────────────────
|
|
|
|
func TestBackupFetchRefusesPrivateAndLocalTargets(t *testing.T) {
|
|
root := t.TempDir()
|
|
cases := []struct{ url, why string }{
|
|
{"http://example.com/a", "plain http"},
|
|
{"https://127.0.0.1/a", "loopback"},
|
|
{"https://localhost/a", "loopback by name"},
|
|
{"https://192.168.1.1/a", "the user's router"},
|
|
{"https://10.0.0.5/a", "private range"},
|
|
{"https://172.16.4.4/a", "private range"},
|
|
{"https://100.64.1.1/a", "carrier-grade NAT / tailnet"},
|
|
{"https://169.254.169.254/a", "cloud metadata"},
|
|
{"https://[fd00:1::10]/a", "a private ULA mesh"},
|
|
{"https://[::1]/a", "IPv6 loopback"},
|
|
}
|
|
for _, c := range cases {
|
|
_, errOut, code := drive(t, root, backupFetchReq{
|
|
Op: "start", URL: c.url, RPath: "a.bin",
|
|
SHA256: strings.Repeat("ab", 32), Size: 10,
|
|
})
|
|
if code == 0 {
|
|
t.Errorf("%s (%s) was ACCEPTED — the wall is open", c.url, c.why)
|
|
continue
|
|
}
|
|
if !strings.Contains(errOut, "backup_fetch:") {
|
|
t.Errorf("%s: refusal should say why, got %q", c.url, errOut)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBackupFetchRefusesBadArguments(t *testing.T) {
|
|
root := t.TempDir()
|
|
good := backupFetchReq{Op: "start", URL: "https://s3.example.com/a",
|
|
RPath: "a.bin", SHA256: strings.Repeat("ab", 32), Size: 10}
|
|
|
|
bad := map[string]backupFetchReq{}
|
|
b := good
|
|
b.RPath = "../../etc/cron.d/pwn"
|
|
bad["path traversal"] = b
|
|
b = good
|
|
b.RPath = "/etc/passwd"
|
|
bad["absolute path"] = b
|
|
b = good
|
|
b.SHA256 = "abc"
|
|
bad["short digest"] = b
|
|
b = good
|
|
b.SHA256 = strings.Repeat("zz", 32)
|
|
bad["non-hex digest"] = b
|
|
b = good
|
|
b.Size, b.MaxBytes = 0, 0
|
|
bad["no size"] = b
|
|
b = good
|
|
b.Size, b.MaxBytes = 0, backupFetchMaxBytes+1
|
|
bad["absurd size"] = b
|
|
|
|
for name, req := range bad {
|
|
_, errOut, code := drive(t, root, req)
|
|
if code == 0 {
|
|
t.Errorf("%s was ACCEPTED", name)
|
|
}
|
|
if !strings.Contains(errOut, "backup_fetch:") {
|
|
t.Errorf("%s: refusal should name the op, got %q", name, errOut)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBackupFetchRefusedWithoutADestination(t *testing.T) {
|
|
// No backup root configured = the op does not exist here, reported the same
|
|
// way an un-allowlisted op is (126), not as a crash.
|
|
_, errOut, code := drive(t, "", backupFetchReq{Op: "start", URL: "https://s3.example.com/a",
|
|
RPath: "a.bin", SHA256: strings.Repeat("ab", 32), Size: 10})
|
|
if code != 126 {
|
|
t.Fatalf("want exit 126 with no destination configured, got %d", code)
|
|
}
|
|
if !strings.Contains(errOut, "backup destination not configured") {
|
|
t.Fatalf("refusal should say what is missing, got %q", errOut)
|
|
}
|
|
}
|
|
|
|
func TestBackupFetchIsIdempotent(t *testing.T) {
|
|
root := t.TempDir()
|
|
body := []byte("already here")
|
|
sum := sha256.Sum256(body)
|
|
want := hex.EncodeToString(sum[:])
|
|
if err := os.WriteFile(filepath.Join(root, "a.bin"), body, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// URL is deliberately unreachable: if the short circuit does not fire, this
|
|
// fails, which is the point — a retry must not re-download gigabytes.
|
|
out, errOut, code := drive(t, root, backupFetchReq{
|
|
Op: "start", URL: "https://s3.example.invalid/a", RPath: "a.bin",
|
|
SHA256: want, Size: int64(len(body)),
|
|
})
|
|
if code != 0 {
|
|
t.Fatalf("an already-correct artifact must report done, got %d: %s", code, errOut)
|
|
}
|
|
var j backupJob
|
|
if err := json.Unmarshal([]byte(out), &j); err != nil || j.State != "done" {
|
|
t.Fatalf("want state=done, got %s (%v)", out, err)
|
|
}
|
|
}
|
|
|
|
func TestBackupFetchStatusOfUnknownJob(t *testing.T) {
|
|
// After a bastion restart the in-memory job is gone. "unknown" lets the host
|
|
// re-start (which is idempotent); "failed" would make it give up.
|
|
out, _, code := drive(t, t.TempDir(), backupFetchReq{Op: "status", JobID: "bf-nope"})
|
|
if code != 0 || !strings.Contains(out, `"state":"unknown"`) {
|
|
t.Fatalf("want a clean unknown, got %d %s", code, out)
|
|
}
|
|
}
|
|
|
|
func TestIsPublicIPClassification(t *testing.T) {
|
|
// The table the wall rests on. Named separately so a regression here points
|
|
// at the classifier rather than at ten URL cases.
|
|
private := []string{"127.0.0.1", "10.1.2.3", "172.20.0.1", "192.168.0.1",
|
|
"169.254.1.1", "100.64.0.1", "100.127.255.255", "::1", "fd00:1::10", "fe80::1", "0.0.0.0"}
|
|
public := []string{"1.1.1.1", "8.8.8.8", "51.68.1.1", "100.63.255.255",
|
|
"100.128.0.1", "2606:4700::1111"}
|
|
for _, s := range private {
|
|
if isPublicIP(mustIP(t, s)) {
|
|
t.Errorf("%s classified PUBLIC — the wall would let it through", s)
|
|
}
|
|
}
|
|
for _, s := range public {
|
|
if !isPublicIP(mustIP(t, s)) {
|
|
t.Errorf("%s classified private — a real backup source would be refused", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The backup lanes judge an address the way a coder run's proxy does: the IPv4
|
|
// address inside a NAT64, 6to4, IPv4-compatible or IPv4-translated address,
|
|
// and the site-local, benchmarking and reserved ranges. The classifier took
|
|
// every address in `refused` as public before; each is now refused at the
|
|
// classifier, the address wall and the URL check. Public addresses in the
|
|
// same forms, and the addresses either side of 198.18.0.0/15, still pass.
|
|
//
|
|
// MUTATION: restore isPublicIP's own list (no unwrapping, no fec0::/10,
|
|
// 198.18.0.0/15 or 240.0.0.0/4).
|
|
// RESULT: red — every address in `refused` is classified public.
|
|
func TestBackupWallJudgesTheIPv4InsideAndTheReservedRanges(t *testing.T) {
|
|
refused := []string{
|
|
"64:ff9b::a00:1", // NAT64 of 10.0.0.1
|
|
"64:ff9b::7f00:1", // NAT64 of 127.0.0.1
|
|
"64:ff9b:1::a00:1", // local-use NAT64 (RFC 8215): not readable here
|
|
"2002:a00:1::1", // 6to4 of 10.0.0.1
|
|
"2002:a9fe:a9fe::", // 6to4 of 169.254.169.254
|
|
"::a00:1", // IPv4-compatible 10.0.0.1
|
|
"::ffff:0:a00:1", // IPv4-translated 10.0.0.1
|
|
"fec0::1", // site-local
|
|
"198.18.0.1", "198.19.255.255", // benchmarking
|
|
"240.0.0.1", "255.255.255.255", // reserved, limited broadcast
|
|
"::ffff:198.18.0.1", "64:ff9b::f000:1", // the same, inside IPv6
|
|
}
|
|
for _, s := range refused {
|
|
ip := mustIP(t, s)
|
|
if isPublicIP(ip) {
|
|
t.Errorf("%s classified PUBLIC — the wall would let it through", s)
|
|
}
|
|
if backupAddrAllowed(ip) {
|
|
t.Errorf("%s passes the backup lanes' address wall", s)
|
|
}
|
|
if err := validateBackupURL("https://" + net.JoinHostPort(s, "443") + "/b.tar"); err == nil {
|
|
t.Errorf("%s passes the backup lanes' URL check", s)
|
|
}
|
|
}
|
|
for _, s := range []string{"64:ff9b::808:808", "2002:808:808::1", "::ffff:8.8.8.8", "198.17.255.255", "198.20.0.1"} {
|
|
if !isPublicIP(mustIP(t, s)) {
|
|
t.Errorf("%s classified private — a real backup source would be refused", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func mustIP(t *testing.T, s string) net.IP {
|
|
t.Helper()
|
|
ip := net.ParseIP(s)
|
|
if ip == nil {
|
|
t.Fatalf("bad test IP %q", s)
|
|
}
|
|
return ip
|
|
}
|
|
|
|
// allowLoopbackFetch lets the dial-time wall (backupDialAllowed) accept the
|
|
// loopback test server for one test; validateBackupURL is not touched.
|
|
func allowLoopbackFetch(t *testing.T) {
|
|
t.Helper()
|
|
prev := backupDialAllowed
|
|
backupDialAllowed = func(net.IP) bool { return true }
|
|
t.Cleanup(func() { backupDialAllowed = prev })
|
|
}
|