Files
privatae-bastion/client-go/backupfetch_test.go
T
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

418 lines
14 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"
"time"
"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 awaitJob(t *testing.T, backupRoot, id string) backupJob {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
out, _, code := drive(t, backupRoot, backupFetchReq{Op: "status", JobID: id})
if code != 0 {
t.Fatalf("status exited %d", code)
}
var j backupJob
if err := json.Unmarshal([]byte(out), &j); err != nil {
t.Fatalf("status is not JSON: %v (%s)", err, out)
}
if j.State != "running" {
return j
}
time.Sleep(25 * time.Millisecond)
}
t.Fatal("job never left running")
return backupJob{}
}
func startAndAwait(t *testing.T, root string, req backupFetchReq) backupJob {
t.Helper()
out, errOut, code := drive(t, root, req)
if code != 0 {
t.Fatalf("start exited %d: %s", code, errOut)
}
var started struct {
ID string `json:"id"`
State string `json:"state"`
}
if err := json.Unmarshal([]byte(out), &started); err != nil {
t.Fatalf("start reply is not JSON: %v (%s)", err, out)
}
if started.State == "done" { // already-present short circuit
var j backupJob
_ = json.Unmarshal([]byte(out), &j)
return j
}
return awaitJob(t, root, started.ID)
}
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 })
}