Files
privatae-bastion/client-go/backuppush_headers_test.go
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

166 lines
5.9 KiB
Go

package main
// Gate: the headers an upload URL was signed with go on the PUT. The
// platform's backup.put_url signs If-None-Match: * into every single-PUT URL
// and returns it under
// `headers`; the storage answers 403 to a PUT without it and 412 to a second
// PUT to the same key. The test server below answers the same way.
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
)
// s3LikeServer answers a PUT as a presigned S3 PUT signed with
// If-None-Match: * does: 403 without the header, 412 when the key is there,
// else 200 and the key is written.
func s3LikeServer(t *testing.T) (*httptest.Server, map[string][]byte) {
t.Helper()
var mu sync.Mutex
stored := map[string][]byte{}
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if r.Method != http.MethodPut || r.Header.Get("If-None-Match") != "*" {
w.WriteHeader(http.StatusForbidden) // SignatureDoesNotMatch
return
}
mu.Lock()
defer mu.Unlock()
if _, ok := stored[r.URL.Path]; ok {
w.WriteHeader(http.StatusPreconditionFailed)
return
}
stored[r.URL.Path] = body
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
old := http.DefaultTransport
http.DefaultTransport = srv.Client().Transport
t.Cleanup(func() { http.DefaultTransport = old })
return srv, stored
}
// The platform's reply, as backup.put_url returns it.
const putURLReply = `{"url": "https://x/put", "key": "k", "expires_at": "t", "size": 8, "headers": {"If-None-Match": "*"}}`
func TestBackupPushSendsTheSignedHeaders(t *testing.T) {
allowLoopbackFetch(t)
root := t.TempDir()
body := []byte("a backup")
_, sum := pushFixture(t, root, body)
srv, stored := s3LikeServer(t)
var reply struct {
Headers map[string]string `json:"headers"`
}
if err := json.Unmarshal([]byte(putURLReply), &reply); err != nil {
t.Fatal(err)
}
abs := filepath.Join(root, fixtureData)
// Without the headers the storage refuses the PUT: the old behaviour.
bare := pushJob(root, sum)
runBackupPush(bare, srv.URL+"/b", abs)
if bare.State != "failed" || !strings.Contains(bare.Error, "403") {
t.Fatalf("a PUT without the signed header: %s %s", bare.State, bare.Error)
}
h, err := backupPushHeaders(reply.Headers)
if err != nil {
t.Fatal(err)
}
job := pushJob(root, sum)
job.headers = h
runBackupPush(job, srv.URL+"/s/obj", abs)
if job.State != "done" || string(stored["/s/obj"]) != string(body) {
t.Fatalf("with the signed header: %s %s", job.State, job.Error)
}
// A second PUT to the same key is refused by the storage, and the job says so.
again := pushJob(root, sum)
again.headers = h
runBackupPush(again, srv.URL+"/s/obj", abs)
if again.State != "failed" || !strings.Contains(again.Error, "412") || !strings.Contains(again.Error, "nothing was replaced") {
t.Fatalf("a second PUT: %s %s", again.State, again.Error)
}
}
// Through the management lane: start keeps the one header the platform signs
// (If-None-Match: *) for the job, and refuses every other header name, and
// any other value for that one, before a job starts.
func TestBackupPushStartTakesTheHeaders(t *testing.T) {
stubPublicDNS(t)
root := t.TempDir()
rel, sum := pushFixture(t, root, []byte("a backup"))
start := func(h map[string]string) (string, string, int) {
return driveBackupOp(t, "backup_push", "", root,
backupPushReq{Op: "start", Path: rel, URL: "https://storage.example/put?sig=1", SHA256: sum, Headers: h})
}
jobHeaders := func(out string) http.Header {
var started map[string]string
_ = json.Unmarshal([]byte(out), &started)
backupJobsMu.Lock()
defer backupJobsMu.Unlock()
if j := backupPushJobs[started["job_id"]]; j != nil {
return j.headers.Clone()
}
return nil
}
for name, h := range map[string]map[string]string{
"as put_url returns it": {"If-None-Match": "*"},
"the name in lower case": {"if-none-match": "*"},
} {
out, errOut, code := start(h)
if code != 0 {
t.Fatalf("%s: start refused the signed header: %s", name, errOut)
}
if got := jobHeaders(out); len(got) != 1 || got.Get("If-None-Match") != "*" {
t.Fatalf("%s: the job carries %v", name, got)
}
}
// No headers at all is refused too: the platform always signs
// If-None-Match: * into the URL and hands it over, so a start without it
// comes from a broken or tampered host. The refusal says so in a sentence.
for name, h := range map[string]map[string]string{
"no headers": nil,
"an empty header set": {},
} {
out, errOut, code := start(h)
if code == 0 {
t.Errorf("%s: accepted (%s)", name, out)
continue
}
if !strings.Contains(errOut, "backup_push: no headers came with the upload address") {
t.Errorf("%s: refused without saying why: %s", name, errOut)
}
}
for name, h := range map[string]map[string]string{
"an unexpected header": {"X-Amz-Meta-Note": "hello"},
"an unexpected header as well": {"If-None-Match": "*", "Authorization": "Bearer x"},
"another If-None-Match value": {"If-None-Match": `"etag"`},
"a padded value": {"If-None-Match": " *"},
"an empty value": {"If-None-Match": ""},
"If-Match": {"If-Match": "*"},
"Host": {"host": "elsewhere.example"},
"Content-Length": {"Content-Length": "1"},
"Transfer-Encoding": {"Transfer-Encoding": "chunked"},
"Connection": {"Connection": "close"},
"a split line": {"If-None-Match": "*\r\nX-Evil: 1"},
"a bad name": {"If None Match": "*"},
"an empty name": {"": "*"},
"a long name": {strings.Repeat("X", 4096): "*"},
} {
_, errOut, code := start(h)
if code == 0 || !strings.Contains(errOut, "backup_push:") {
t.Errorf("%s: accepted (%s)", name, errOut)
}
if len(errOut) > 400 {
t.Errorf("%s: the refusal quotes %d bytes", name, len(errOut))
}
}
}