初始化蓝鸟文件快递柜源码与部署文档
This commit is contained in:
commit
5643d22242
70 files changed
+18669
No files matched your search
@@ -0,0 +1,227 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user