Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ services:
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
VIDEO_STORAGE_BUCKET: ${VIDEO_STORAGE_BUCKET:-videos}
METADATA_URL: http://metadata:8002
INTERNAL_TOKEN: ${INTERNAL_TOKEN:-}
JWT_SECRET: ${JWT_SECRET}
UPLOAD_MAX_BYTES: ${UPLOAD_MAX_BYTES:-5368709120}
MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}
Expand Down
3 changes: 3 additions & 0 deletions services/upload/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ open http://localhost:8003/swagger/doc.json
## Лимиты
`UPLOAD_MAX_BYTES` (по умолчанию 5GB) ограничивает тело multipart-загрузки и resumable-путей (`PUT /api/v1/videos/{id}/resumable` — и полный PUT, и суммарный размер объекта по чанкам). Превышение → `413 Request Entity Too Large`. На gateway тот же лимит через `maxBytesMw`.

## Ownership (issue #46)
`complete`, `GET/PUT resumable` и presign-пути перед записью в `raw/{id}/*` проверяют владельца через metadata internal-API (`GET /internal/videos/{id}`, заголовок `X-Internal-Token` из `INTERNAL_TOKEN`): чужой id → `403`, несуществующий → `404`, metadata недоступна → `502` (fail-closed). `complete` дополнительно сверяет объект в MinIO: пустой или не-`video/*` content-type → `400`.

## Линт / формат
```bash
make fmt-go
Expand Down
6 changes: 3 additions & 3 deletions services/upload/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,10 @@ func main() {
}
defer pub.Close()

metaCl := client.NewMetadataClient(metadataURL)
metaCl := client.NewMetadataClient(metadataURL, os.Getenv("INTERNAL_TOKEN"))
uh := handler.NewUploadHandler(store, pub, metaCl)
ph := handler.NewPresignHandler(store, pub, metaCl)
rh := handler.NewResumableHandler(store)
ph := handler.NewPresignHandler(store, pub, metaCl, metaCl)
rh := handler.NewResumableHandler(store, metaCl)

r := chi.NewRouter()
r.Use(middleware.RequestID, middleware.Recoverer, mw.RequestLogger)
Expand Down
40 changes: 36 additions & 4 deletions services/upload/internal/client/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,28 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
)

type MetadataClient struct {
baseURL string
client *http.Client
baseURL string
internalToken string
client *http.Client
}

func NewMetadataClient(baseURL string) *MetadataClient {
return &MetadataClient{baseURL: baseURL, client: &http.Client{}}
func NewMetadataClient(baseURL, internalToken string) *MetadataClient {
return &MetadataClient{baseURL: baseURL, internalToken: internalToken, client: &http.Client{}}
}

type CreateVideoResponse struct {
ID string `json:"id"`
}

type internalVideoResponse struct {
ID string `json:"id"`
OwnerID string `json:"owner_id"`
}

func (c *MetadataClient) CreateVideo(token, title, description string) (string, error) {
body, _ := json.Marshal(map[string]string{"title": title, "description": description})
req, err := http.NewRequest("POST", c.baseURL+"/api/v1/videos", bytes.NewReader(body))
Expand All @@ -45,3 +52,28 @@ func (c *MetadataClient) CreateVideo(token, title, description string) (string,
}
return out.ID, nil
}

// GetVideoOwner resolves the owner of a video via metadata internal API
// (upload handlers must verify ownership before writing raw/{id}/* — issue #46).
func (c *MetadataClient) GetVideoOwner(videoID string) (string, error) {
req, err := http.NewRequest("GET", c.baseURL+"/internal/videos/"+url.PathEscape(videoID), nil)
if err != nil {
return "", err
}
if c.internalToken != "" {
req.Header.Set("X-Internal-Token", c.internalToken)
}
resp, err := c.client.Do(req)
if err != nil {
return "", fmt.Errorf("metadata owner: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("metadata owner status %d", resp.StatusCode)
}
var out internalVideoResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
return out.OwnerID, nil
}
76 changes: 76 additions & 0 deletions services/upload/internal/client/metadata_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package client

import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestGetVideoOwner(t *testing.T) {
var gotPath, gotToken string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotToken = r.Header.Get("X-Internal-Token")
w.Header().Set("Content-Type", "application/json")
if gotToken != "test-internal-token" {
http.Error(w, `{"error":"unauthorized internal"}`, http.StatusUnauthorized)
return
}
switch r.URL.Path {
case "/internal/videos/vid-1":
_, _ = w.Write([]byte(`{"id":"vid-1","owner_id":"owner-1"}`))
case "/internal/videos/vid-404":
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
default:
http.Error(w, `{"error":"unauthorized internal"}`, http.StatusUnauthorized)
}
}))
defer srv.Close()

c := NewMetadataClient(srv.URL, "test-internal-token")

owner, err := c.GetVideoOwner("vid-1")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if owner != "owner-1" {
t.Fatalf("owner = %q, want owner-1", owner)
}
if gotPath != "/internal/videos/vid-1" {
t.Fatalf("path = %q", gotPath)
}
if gotToken != "test-internal-token" {
t.Fatalf("X-Internal-Token = %q", gotToken)
}

// unknown video: error must carry "404" so handlers classify it as not-found
_, err = c.GetVideoOwner("vid-404")
if err == nil || !strings.Contains(err.Error(), "404") {
t.Fatalf("err = %v, want it to contain 404", err)
}

// wrong token -> 401 from metadata, not classified as 404
cNoToken := NewMetadataClient(srv.URL, "wrong-token")
_, err = cNoToken.GetVideoOwner("vid-1")
if err == nil || strings.Contains(err.Error(), "404") {
t.Fatalf("err = %v, want non-404 for 401", err)
}
}

func TestGetVideoOwnerOmitsEmptyToken(t *testing.T) {
var sawHeader bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawHeader = r.Header.Get("X-Internal-Token") != ""
_, _ = w.Write([]byte(`{"id":"v","owner_id":"o"}`))
}))
defer srv.Close()

c := NewMetadataClient(srv.URL, "")
if _, err := c.GetVideoOwner("v"); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if sawHeader {
t.Fatal("X-Internal-Token must be omitted when internalToken is empty")
}
}
33 changes: 33 additions & 0 deletions services/upload/internal/handler/ownership.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package handler

import (
"net/http"
"strings"
)

// OwnershipChecker resolves the video owner via metadata internal API.
// Required before any write to raw/{id}/* — presign/complete/resumable
// used to trust the video UUID from the URL (IDOR, issue #46).
type OwnershipChecker interface {
GetVideoOwner(videoID string) (string, error)
}

// requireOwnership verifies userID owns videoID. On failure it writes the
// response (403 wrong owner, 404 unknown video, 502 metadata unavailable —
// fail closed) and returns false.
func requireOwnership(checker OwnershipChecker, videoID, userID string, w http.ResponseWriter) bool {
owner, err := checker.GetVideoOwner(videoID)
if err != nil {
if strings.Contains(err.Error(), "404") {
http.Error(w, `{"error":"video not found"}`, http.StatusNotFound)
return false
}
http.Error(w, `{"error":"metadata unavailable"}`, http.StatusBadGateway)
return false
}
if owner != userID {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return false
}
return true
}
22 changes: 19 additions & 3 deletions services/upload/internal/handler/presign.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
type PresignStorage interface {
PresignedPutObjectExternal(ctx context.Context, key string, expires time.Duration, publicEndpoint string) (string, error)
StatObject(ctx context.Context, key string) error
StatObjectInfo(ctx context.Context, key string) (int64, string, error)
}

// We use context.Context but avoid import cycle; interface matches storage.
Expand All @@ -41,10 +42,11 @@ type PresignHandler struct {
storage PresignStorage
publisher Publisher
metadata MetadataCreator
owner OwnershipChecker
}

func NewPresignHandler(s PresignStorage, p Publisher, m MetadataCreator) *PresignHandler {
return &PresignHandler{storage: s, publisher: p, metadata: m}
func NewPresignHandler(s PresignStorage, p Publisher, m MetadataCreator, o OwnershipChecker) *PresignHandler {
return &PresignHandler{storage: s, publisher: p, metadata: m, owner: o}
}

// Presign godoc
Expand Down Expand Up @@ -139,11 +141,25 @@ func (h *PresignHandler) Complete(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"video_id required"}`, 400)
return
}
// IDOR (issue #46): only the owner may complete an upload for a video id.
if !requireOwnership(h.owner, videoID, ownerID, w) {
return
}
s3Key := fmt.Sprintf("raw/%s/original.mp4", videoID)
if err := h.storage.StatObject(r.Context(), s3Key); err != nil {
// Verify the uploaded object matches a real video PUT (issue #46).
size, contentType, err := h.storage.StatObjectInfo(r.Context(), s3Key)
if err != nil {
http.Error(w, `{"error":"object not found, upload via presigned URL first"}`, 404)
return
}
if size == 0 {
http.Error(w, `{"error":"empty object"}`, 400)
return
}
if contentType != "" && !strings.HasPrefix(contentType, "video/") {
http.Error(w, `{"error":"unexpected content type: `+contentType+`"}`, 400)
return
}
metrics.UploadBytes.Inc()
ev := VideoUploadedEvent{VideoID: videoID, S3Key: s3Key, OwnerID: ownerID}
if err := h.publisher.Publish(r.Context(), ev); err != nil {
Expand Down
Loading
Loading