166 lines
5.9 KiB
Go
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))
|
|
}
|
|
}
|
|
}
|