package app import ( "bytes" "encoding/json" "net/http/cookiejar" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "github.com/pelletier/go-toml/v2" ) func TestMultipleAccessOriginsAndProtocolCookies(t *testing.T) { c := fixture(t, true) c.a.cfg.PublicURL = "https://files.example.com" c.a.cfg.AccessURLs = []string{"http://files.example.com", "http://192.168.1.210:8080"} c.a.cfg.SecureCookies = true c.a.cfg.AllowLANHTTP = true c.a.cfg.Listen = "192.168.1.210:8080" if err := c.a.cfg.Validate(); err != nil { t.Fatal(err) } jar, _ := cookiejar.New(nil) request := func(base, method, path string, body any, hint bool) *httptest.ResponseRecorder { var data []byte if body != nil { switch v := body.(type) { case []byte: data = v default: data, _ = json.Marshal(v) } } r := httptest.NewRequest(method, base+"/api/v1"+path, bytes.NewReader(data)) r.TLS = nil // Real HTTPS is terminated on a separate proxy, which rewrites Host. r.Host = "192.168.1.210:8080" if base == "https://files.example.com" { r.RemoteAddr = "192.168.1.60:1000" } else if base == "http://files.example.com" { r.RemoteAddr = "192.168.1.61:1000" } else { r.RemoteAddr = "192.168.1.62:1000" } if method != "GET" { r.Header.Set("Origin", base) } if hint { r.Header.Set("X-Bluebird-Origin", base) } u, _ := url.Parse(base + "/api/v1" + path) for _, cookie := range jar.Cookies(u) { r.AddCookie(cookie) } w := httptest.NewRecorder() c.h.ServeHTTP(w, r) jar.SetCookies(u, w.Result().Cookies()) return w } for _, base := range c.a.cfg.accessOrigins() { w := request(base, "GET", "/policy", nil, true) expect(t, w, 200) var policy map[string]any json.Unmarshal(w.Body.Bytes(), &policy) if policy["public_url"] != base { t.Fatal("policy forced a different origin") } cookies := w.Result().Cookies() if len(cookies) != 1 { t.Fatal("visitor cookie not returned") } https := strings.HasPrefix(base, "https:") if cookies[0].Secure != https { t.Fatalf("incorrect Secure flag for %s", base) } want := "bb_visitor" if !https { want += "_http" } if cookies[0].Name != want { t.Fatal("HTTP can overwrite HTTPS cookie") } w = request(base, "POST", "/admin/login", map[string]string{"username": "admin", "password": "test-password"}, true) expect(t, w, 200) if w.Result().Cookies()[0].Secure != https { t.Fatal("admin cookie protocol incorrect") } expect(t, request(base, "GET", "/admin/stats", nil, true), 200) } // HTTPS and HTTP on the same public hostname retain independent sessions. u, _ := url.Parse("http://files.example.com/api/v1/admin/stats") for _, cookie := range jar.Cookies(u) { if cookie.Name == "bb_admin" { t.Fatal("HTTPS credential sent over HTTP") } } payload := []byte("available with failed public DNS") w := request("http://192.168.1.210:8080", "POST", "/uploads", Manifest{Files: []ManifestFile{{Name: "offline.txt", Size: int64(len(payload))}}, Days: 7, Downloads: 8, Renew: true}, true) expect(t, w, 201) var parcel struct { ID string Files []struct{ ID string } } json.Unmarshal(w.Body.Bytes(), &parcel) expect(t, request("http://192.168.1.210:8080", "PUT", "/uploads/"+parcel.ID+"/files/"+parcel.Files[0].ID, payload, true), 200) w = request("http://192.168.1.210:8080", "POST", "/uploads/"+parcel.ID+"/publish", map[string]any{}, true) expect(t, w, 200) var share struct { Code, URL string URLs []map[string]string } json.Unmarshal(w.Body.Bytes(), &share) if !strings.HasPrefix(share.URL, "http://192.168.1.210:8080/") || len(share.URLs) != 3 { t.Fatal("share did not offer independent LAN/public URLs") } for _, base := range c.a.cfg.accessOrigins() { expect(t, request(base, "POST", "/pickup/resolve", map[string]string{"code": share.Code}, true), 200) // Navigation downloads have neither Origin nor the frontend access hint. w = request(base, "GET", "/pickup/files/"+parcel.Files[0].ID+"/download", nil, false) expect(t, w, 200) if !bytes.Equal(w.Body.Bytes(), payload) { t.Fatal("cross-entry download failed") } } // The same hostname can hold different pickup grants on HTTP and HTTPS. other := []byte("a different public HTTP parcel") w = request("http://files.example.com", "POST", "/uploads", Manifest{Files: []ManifestFile{{Name: "other.txt", Size: int64(len(other))}}, Days: 7, Downloads: 4}, true) expect(t, w, 201) var second struct { ID string Files []struct{ ID string } } json.Unmarshal(w.Body.Bytes(), &second) expect(t, request("http://files.example.com", "PUT", "/uploads/"+second.ID+"/files/"+second.Files[0].ID, other, true), 200) w = request("http://files.example.com", "POST", "/uploads/"+second.ID+"/publish", map[string]any{}, true) expect(t, w, 200) var otherShare struct{ Code string } json.Unmarshal(w.Body.Bytes(), &otherShare) expect(t, request("https://files.example.com", "POST", "/pickup/resolve", map[string]string{"code": share.Code}, true), 200) expect(t, request("http://files.example.com", "POST", "/pickup/resolve", map[string]string{"code": otherShare.Code}, true), 200) w = request("https://files.example.com", "GET", "/pickup/files/"+parcel.Files[0].ID+"/download", nil, false) expect(t, w, 200) if !bytes.Equal(w.Body.Bytes(), payload) { t.Fatal("HTTP grant shadowed the HTTPS grant") } w = request("http://files.example.com", "GET", "/pickup/files/"+second.Files[0].ID+"/download", nil, false) expect(t, w, 200) if !bytes.Equal(w.Body.Bytes(), other) { t.Fatal("HTTPS grant shadowed the HTTP grant") } for _, header := range []struct{ origin, hint string }{{"https://evil.example", ""}, {"", "http://attacker.example"}, {"https://files.example.com", "http://files.example.com"}, {"http://192.168.1.211:8080", ""}} { r := httptest.NewRequest("POST", "http://192.168.1.210:8080/api/v1/admin/login", strings.NewReader(`{"username":"admin","password":"test-password"}`)) r.Header.Set("Origin", header.origin) r.Header.Set("X-Bluebird-Origin", header.hint) out := httptest.NewRecorder() c.h.ServeHTTP(out, r) expect(t, out, 403) } // Neither Host nor forwarded headers can inject a foreign share URL. r := httptest.NewRequest("GET", "http://attacker.example/api/v1/policy", nil) r.RemoteAddr = "203.0.113.4:4000" r.Header.Set("X-Forwarded-Host", "evil.example") r.Header.Set("X-Forwarded-Proto", "http") out := httptest.NewRecorder() c.h.ServeHTTP(out, r) expect(t, out, 200) if strings.Contains(out.Body.String(), "evil.example") || strings.Contains(out.Body.String(), "attacker.example") { t.Fatal("untrusted forwarding/Host used as site URL") } } func TestAccessConfigurationValidationAndAtomicUpdate(t *testing.T) { cfg := DefaultConfig() cfg.PublicURL = "https://files.example.com" cfg.SecureCookies = true cfg.AccessURLs = []string{"http://files.example.com", "http://192.168.1.210:8080"} if cfg.Validate() == nil { t.Fatal("private HTTP accepted without explicit opt-in") } cfg.AllowLANHTTP = true if err := cfg.Validate(); err != nil { t.Fatal(err) } for _, bad := range []string{"null", "https://", "https://:443", "http://user:pass@files.example.com", "http://files.example.com/path", "http://files.example.com?x=1", "ftp://files.example.com", "https://files.example.com:70000"} { cfg.AccessURLs = []string{bad} if cfg.Validate() == nil { t.Fatalf("accepted bad origin %q", bad) } } cfg.AccessURLs = nil directory := t.TempDir() path := filepath.Join(directory, "config.toml") original, _ := toml.Marshal(cfg) if err := os.WriteFile(path, original, 0640); err != nil { t.Fatal(err) } if err := UpdateAccessConfig(path, "192.168.1.210:8080", []string{"http://files.example.com", "http://192.168.1.210:8080"}, true); err != nil { t.Fatal(err) } saved, err := LoadConfig(path) if err != nil { t.Fatal(err) } if len(saved.AccessURLs) != 2 || saved.PublicURL != cfg.PublicURL || saved.Encryption != cfg.Encryption || saved.FileBudget != cfg.FileBudget || saved.Listen != "192.168.1.210:8080" { t.Fatal("access update reset existing settings") } if _, err = os.Stat(filepath.Join(directory, "var")); !os.IsNotExist(err) { t.Fatal("access configuration opened runtime data") } updated, _ := os.ReadFile(path) if err = UpdateAccessConfig(path, "", []string{"http://evil.example/path"}, false); err == nil { t.Fatal("invalid access update accepted") } intact, _ := os.ReadFile(path) if !bytes.Equal(updated, intact) { t.Fatal("failed update changed live configuration") } info, _ := os.Stat(path) if info.Mode().Perm() != 0640 { t.Fatal("configuration permissions changed") } if _, _, err = LANListen("http://files.example.com:8080"); err == nil { t.Fatal("LAN flag accepted DNS-dependent domain") } }