Files
Felis/internal/submit/blobstore.go
T
Lemon-miaow ad4d256d8f fix(submit): bound the untrusted upload lane — per-user caps + throttles (#75)
A logged-in user could file submissions without bound and stream a 1 GiB
context per submission. The only limits were the single-blob size cap and the
5 GiB uploads PVC (platform/workloads.go); nothing counted a user's rows or
bytes, so one account could fill the volume and every other user's upload
would start failing.

- Create: per-user pending_review cap (default 5) — the review queue cannot
  be parked full of one account's rows. Check-then-insert, documented soft.
- UploadContext: per-user stored-context budget (default 2 GiB) charged
  against the blob store's REAL sizes (new Blobs.Size on local/S3 stores), so
  the sum cannot drift from the volume; the write is capped at the remaining
  budget, so the excess is refused before it is persisted, and a re-upload is
  charged only for its new bytes.
- API: per-user create/upload throttles (30s/15s, cmd/felis-wired) on a
  dedicated cooldown keyspace, reserve→release so a failed attempt never
  burns the window and a burst collapses to one winner; ErrQuotaExceeded →
  403 submission_quota_exceeded (distinct from the 400 an oversize blob
  gets), 429 submission_cooldown for the throttles.
- Panel: zh/en copy for both codes; openapi documents 403/429 on the two
  user routes; pgint covers the pending-queue count.

Unit tests: submit package (cap, budget boundary/exact-fit/replacement,
oversize-vs-quota split) and api handlers (quota 403 both paths, throttle
429 + recovery + failure-release). go vet/go test/gofmt clean; panel
vitest 118 + typecheck green.
2026-09-24 10:14:42 +08:00

154 lines
5.9 KiB
Go

package submit
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
)
// contextBlobName is the fixed object name of a submission's build context under
// its id-namespaced prefix. It is the single source of truth for both the
// derived context ref (deriveContextRef) and the on-disk write target
// (LocalContextStore), so the blob always lands exactly where Kaniko's
// --context points (build/jobspec.go).
const contextBlobName = "context.tar.gz"
// idRE re-validates a submission id at the storage boundary. The Manager only
// ever passes ids it loaded from the Store (already the crypto-hex ids newID
// mints), but LocalContextStore is a standalone component that treats the id as
// untrusted path input: a lowercase-alphanumeric-with-dashes id can contain no
// path separator and no "..", so it can never escape Base. This is the same
// defense-in-depth stance as internal/backup's zip-slip guard.
var idRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,127}$`)
// LocalContextStore is the filesystem-backed build-context blob store: it writes
// each submission's uploaded modpack to {Base}/{id}/context.tar.gz on a mounted
// PVC. It is one of two implemented Blobs backends — cmd/felis selects it when
// user_uploads_context is a local path and S3ContextStore when it is an s3:// base;
// a base that is neither (or an s3:// base with no credentials configured) leaves
// Manager.Blobs nil so the upload endpoint returns 503 rather than pretending to
// accept a file it cannot persist.
//
// Base MUST equal the Manager's ContextStore so the blob lands exactly where
// deriveContextRef points Kaniko's --context; cmd/felis wires both from the one
// config field (registry.user_uploads_context).
//
// INTEGRATION-ONLY seam (out of scope of the upload transport): persisting the
// blob is end-to-end only once the same uploads PVC is mounted into the Kaniko
// build Pod and Kaniko is told to read a local context (build/jobspec.go passes
// the ref straight into --context). The transport here makes the file durable at
// the derived location; wiring that path into the sandboxed build Job is a
// separate deployment integration, exactly like the restore executor's PVC mount.
type LocalContextStore struct {
// Base is the directory (uploads PVC mount) submission contexts are written
// under. Each submission gets its own {Base}/{id}/ subdirectory.
Base string
}
// dir returns the per-submission directory, rejecting an id that could escape
// Base. Every path the store touches is rooted here.
func (s *LocalContextStore) dir(id string) (string, error) {
if !idRE.MatchString(id) {
return "", fmt.Errorf("submit: invalid submission id %q", id)
}
return filepath.Join(s.Base, id), nil
}
// Put writes r to {Base}/{id}/context.tar.gz atomically: it streams into a temp
// file in the same directory and renames it over any previous upload only on a
// fully successful copy. So a failed, truncated, or oversize upload never
// replaces a good context and never leaves a half-written blob for Kaniko to
// read; a re-upload while the submission is still pending simply supersedes the
// previous one. It returns the number of bytes stored.
func (s *LocalContextStore) Put(_ context.Context, id string, r io.Reader) (int64, error) {
dir, err := s.dir(id)
if err != nil {
return 0, err
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return 0, fmt.Errorf("submit: mkdir context dir: %w", err)
}
tmp, err := os.CreateTemp(dir, contextBlobName+".*.tmp")
if err != nil {
return 0, fmt.Errorf("submit: create temp context: %w", err)
}
tmpName := tmp.Name()
n, err := io.Copy(tmp, r)
if err != nil {
tmp.Close()
os.Remove(tmpName)
return 0, fmt.Errorf("submit: write context blob: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return 0, fmt.Errorf("submit: close context blob: %w", err)
}
if err := os.Rename(tmpName, filepath.Join(dir, contextBlobName)); err != nil {
os.Remove(tmpName)
return 0, fmt.Errorf("submit: commit context blob: %w", err)
}
return n, nil
}
// Exists reports whether a context blob has been stored for id. Approve consults
// it so a submission whose context was never uploaded is refused BEFORE the CAS,
// instead of being approved into a build Kaniko cannot pull.
func (s *LocalContextStore) Exists(_ context.Context, id string) (bool, error) {
dir, err := s.dir(id)
if err != nil {
return false, err
}
switch _, err := os.Stat(filepath.Join(dir, contextBlobName)); {
case err == nil:
return true, nil
case os.IsNotExist(err):
return false, nil
default:
return false, fmt.Errorf("submit: stat context blob: %w", err)
}
}
// Open returns the stored context blob for id — the read side of the transport the
// build Pod's fetch initContainer uses. A missing blob is ErrBlobNotFound (404 on
// the route), never a bare os error, so the API keeps its status mapping.
func (s *LocalContextStore) Open(_ context.Context, id string) (io.ReadCloser, error) {
dir, err := s.dir(id)
if err != nil {
return nil, err
}
f, err := os.Open(filepath.Join(dir, contextBlobName))
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("%w: %v", ErrBlobNotFound, err)
}
return nil, fmt.Errorf("submit: open context blob: %w", err)
}
return f, nil
}
// Size reports the stored blob's size — the accounting read behind the per-user
// storage budget. A missing blob is (0, false, nil): absence is not an error
// here, it is simply no bytes to count (the same distinction Exists draws for
// Approve).
func (s *LocalContextStore) Size(_ context.Context, id string) (int64, bool, error) {
dir, err := s.dir(id)
if err != nil {
return 0, false, err
}
fi, err := os.Stat(filepath.Join(dir, contextBlobName))
switch {
case err == nil:
return fi.Size(), true, nil
case os.IsNotExist(err):
return 0, false, nil
default:
return 0, false, fmt.Errorf("submit: stat context blob: %w", err)
}
}
// Compile-time proof that the filesystem store satisfies the Blobs transport.
var _ Blobs = (*LocalContextStore)(nil)