package gateway import ( "bytes" "context" "encoding/json" "io" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "strings" "testing" "testing/fstest" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" ) // testPNG is the smallest byte string the stdlib sniffer recognizes as // image/png (the 8-byte signature plus padding); the handler only sniffs the // head, it never decodes the image. var testPNG = append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...) // uploadTestEnv builds a Server whose data dir is a fresh temp dir and returns // the httptest server, the JWT service and the resolved data dir. func uploadTestEnv(t *testing.T) (*httptest.Server, *auth.JWTService, string) { t.Helper() cfg := cfgDefault() cfg.DataDir = t.TempDir() _, ts, jwt := buildEnv(t, cfg, Deps{}) return ts, jwt, cfg.DataDir } // postUpload sends a multipart/form-data POST to /api/v1/upload. field is the // part name (normally "file"); filename is the client-supplied name, which the // handler must ignore. func postUpload(t *testing.T, ts *httptest.Server, token, field, filename string, content []byte) (*http.Response, []byte) { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) part, err := mw.CreateFormFile(field, filename) if err != nil { t.Fatalf("create form file: %v", err) } if _, err := part.Write(content); err != nil { t.Fatalf("write part: %v", err) } if err := mw.Close(); err != nil { t.Fatalf("close writer: %v", err) } req, err := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/upload", &buf) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("Content-Type", mw.FormDataContentType()) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := ts.Client().Do(req) if err != nil { t.Fatalf("do request: %v", err) } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read response: %v", err) } return resp, body } // uploadData is the decoded upload response. type uploadData struct { URL string `json:"url"` Size int64 `json:"size"` MIME string `json:"mime"` Name string `json:"name"` } func decodeUploadData(t *testing.T, body []byte) uploadData { t.Helper() var out uploadData if err := json.Unmarshal(decodeBody(t, body)["data"], &out); err != nil { t.Fatalf("decode upload data: %v (body=%s)", err, body) } return out } func TestUploadAndServeImage(t *testing.T) { t.Parallel() ts, jwt, dataDir := uploadTestEnv(t) tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour) if err != nil { t.Fatalf("sign token: %v", err) } resp, body := postUpload(t, ts, tok, "file", "../../evil.png", testPNG) if resp.StatusCode != http.StatusCreated { t.Fatalf("status = %d, want 201 (body=%s)", resp.StatusCode, body) } data := decodeUploadData(t, body) if data.MIME != "image/png" { t.Fatalf("mime = %q, want image/png", data.MIME) } if data.Size != int64(len(testPNG)) { t.Fatalf("size = %d, want %d", data.Size, len(testPNG)) } if !strings.HasPrefix(data.URL, "http://") || !strings.Contains(data.URL, uploadFilePrefix) { t.Fatalf("url = %q, want absolute %s URL", data.URL, uploadFilePrefix) } // The client's original filename must never reach disk or the response. if strings.Contains(data.Name, "evil") || strings.ContainsAny(data.Name, "/\\") { t.Fatalf("name = %q, want a server-generated hex name", data.Name) } if !validUploadName(data.Name) { t.Fatalf("name = %q, not a valid storage name", data.Name) } if _, err := os.Stat(filepath.Join(dataDir, "uploads", data.Name)); err != nil { t.Fatalf("stored file: %v", err) } // The static route is a capability URL: no bearer token, and the bytes and // caching/type headers must come back intact. get, err := ts.Client().Get(data.URL) if err != nil { t.Fatalf("get upload: %v", err) } defer func() { _ = get.Body.Close() }() if get.StatusCode != http.StatusOK { t.Fatalf("get status = %d, want 200", get.StatusCode) } if ct := get.Header.Get("Content-Type"); ct != "image/png" { t.Fatalf("content-type = %q, want image/png", ct) } if cl := get.Header.Get("Content-Length"); cl != strconv.Itoa(len(testPNG)) { t.Fatalf("content-length = %q, want %d", cl, len(testPNG)) } if get.Header.Get("Cache-Control") == "" { t.Fatal("cache-control is empty") } got, err := io.ReadAll(get.Body) if err != nil { t.Fatalf("read served body: %v", err) } if !bytes.Equal(got, testPNG) { t.Fatalf("served body = %q, want the uploaded bytes", got) } } func TestUploadRejectsNonImage(t *testing.T) { t.Parallel() ts, jwt, _ := uploadTestEnv(t) tok, _ := jwt.Sign("acct", auth.RoleOwner, time.Hour) resp, body := postUpload(t, ts, tok, "file", "notes.txt", []byte("just some plain text")) if resp.StatusCode != http.StatusUnsupportedMediaType { t.Fatalf("status = %d, want 415 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) } func TestUploadRejectsOversize(t *testing.T) { t.Parallel() ts, jwt, _ := uploadTestEnv(t) tok, _ := jwt.Sign("acct", auth.RoleOwner, time.Hour) big := make([]byte, maxUploadBytes+1) copy(big, testPNG) resp, body := postUpload(t, ts, tok, "file", "big.png", big) if resp.StatusCode != http.StatusRequestEntityTooLarge { t.Fatalf("status = %d, want 413 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) } func TestUploadMissingFile(t *testing.T) { t.Parallel() ts, jwt, _ := uploadTestEnv(t) tok, _ := jwt.Sign("acct", auth.RoleOwner, time.Hour) resp, body := postUpload(t, ts, tok, "wrongfield", "x.png", testPNG) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) } func TestUploadRequiresAuth(t *testing.T) { t.Parallel() ts, jwt, _ := uploadTestEnv(t) viewer, _ := jwt.Sign("acct", auth.RoleViewer, time.Hour) if resp, body := postUpload(t, ts, "", "file", "x.png", testPNG); resp.StatusCode != http.StatusUnauthorized { t.Fatalf("no token status = %d, want 401 (body=%s)", resp.StatusCode, body) } if resp, body := postUpload(t, ts, viewer, "file", "x.png", testPNG); resp.StatusCode != http.StatusForbidden { t.Fatalf("viewer status = %d, want 403 (body=%s)", resp.StatusCode, body) } } func TestUploadAudits(t *testing.T) { t.Parallel() cfg := cfgDefault() cfg.DataDir = t.TempDir() store := newMemoryAuditStore() _, ts, jwt := buildEnv(t, cfg, Deps{AuditStore: store}) tok, _ := jwt.Sign("acct", auth.RoleOwner, time.Hour) if resp, body := postUpload(t, ts, tok, "file", "x.png", testPNG); resp.StatusCode != http.StatusCreated { t.Fatalf("status = %d, want 201 (body=%s)", resp.StatusCode, body) } records, err := store.List(context.Background(), 0) if err != nil { t.Fatalf("list audit: %v", err) } found := false for _, rec := range records { if rec.What == "upload.image" && rec.Result == "ok" { found = true } } if !found { t.Fatalf("no upload.image audit record in %+v", records) } } func TestUploadFileServedThroughSPA(t *testing.T) { t.Parallel() cfg := cfgDefault() cfg.DataDir = t.TempDir() // A non-nil frontend enables the SPA wrapper in Handler; the /api/v1/files/ // route must still reach the API mux instead of falling back to index.html. frontend := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("spa")}} _, ts, jwt := buildEnv(t, cfg, Deps{Frontend: frontend}) tok, _ := jwt.Sign("acct", auth.RoleOwner, time.Hour) resp, body := postUpload(t, ts, tok, "file", "x.png", testPNG) if resp.StatusCode != http.StatusCreated { t.Fatalf("status = %d, want 201 (body=%s)", resp.StatusCode, body) } data := decodeUploadData(t, body) get, err := ts.Client().Get(data.URL) if err != nil { t.Fatalf("get upload: %v", err) } defer func() { _ = get.Body.Close() }() if get.StatusCode != http.StatusOK { t.Fatalf("get status = %d, want 200 (SPA must not swallow the route)", get.StatusCode) } got, _ := io.ReadAll(get.Body) if !bytes.Equal(got, testPNG) { t.Fatalf("served body = %q, want the uploaded bytes", got) } } func TestUploadFileRejectsTraversal(t *testing.T) { t.Parallel() cfg := cfgDefault() cfg.DataDir = t.TempDir() // A non-nil frontend reproduces production routing: without the explicit // guard, ServeMux would clean the raw `..` path and redirect to a path the SPA // then answers with index.html. frontend := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("spa")}} _, ts, _ := buildEnv(t, cfg, Deps{Frontend: frontend}) // A file that really exists directly under the data dir (not in uploads/) // must not be reachable through the static route. if err := os.WriteFile(filepath.Join(cfg.DataDir, "secret.png"), testPNG, 0o600); err != nil { t.Fatalf("write decoy: %v", err) } paths := []string{ "/api/v1/files/", "/api/v1/files/..%2f..%2f..%2fetc%2fpasswd", "/api/v1/files/../../../../etc/passwd", "/api/v1/files/%2e%2e%2fsecret.png", "/api/v1/files/secret.png", "/api/v1/files/00000000000000000000000000000000.png", } for _, p := range paths { resp, err := ts.Client().Get(ts.URL + p) if err != nil { t.Fatalf("get %s: %v", p, err) } body, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Fatalf("get %s status = %d, want 404", p, resp.StatusCode) } if bytes.Contains(body, []byte("root:")) { t.Fatalf("get %s leaked /etc/passwd", p) } } } func TestValidUploadName(t *testing.T) { t.Parallel() cases := []struct { name string want bool }{ {name: "0123456789abcdef0123456789abcdef.png", want: true}, {name: "0123456789abcdef0123456789abcdef.webp", want: true}, {name: "", want: false}, {name: ".", want: false}, {name: "..", want: false}, {name: "abcd.png", want: false}, {name: "0123456789abcdef0123456789abcdef.exe", want: false}, {name: "0123456789abcdef0123456789abcdeZ.png", want: false}, {name: "0123456789abcdef0123456789abcdef", want: false}, {name: "../0123456789abcdef0123456789abcdef.png", want: false}, {name: "0123456789abcdef0123456789abcdef.png/../x.png", want: false}, } for _, tc := range cases { if got := validUploadName(tc.name); got != tc.want { t.Errorf("validUploadName(%q) = %v, want %v", tc.name, got, tc.want) } } }