fix(submit): 审阅绑定上下文 sha256,批准须带摘要,构建只从内部 API 取上下文并校验字节

This commit is contained in:
Lemon-miaow committed 2026-09-24 22:24:50 +08:00
1 parent 13b65e19ec
commit e521cf5976
30 files changed
+910 -98

No files matched your search

+3
View File
@@ -469,6 +469,9 @@ func buildConfig(cfg *config.Config) build.Config {
// the internal DB mirror (see config.RegistryConfig.TrivyDBRepository). // the internal DB mirror (see config.RegistryConfig.TrivyDBRepository).
TrivyDBRepository: cfg.Registry.TrivyDBRepository, TrivyDBRepository: cfg.Registry.TrivyDBRepository,
TrivyJavaDBRepository: cfg.Registry.TrivyJavaDBRepository, TrivyJavaDBRepository: cfg.Registry.TrivyJavaDBRepository,
// The submit lane's derived context URLs live here; the fetch step's
// service token goes nowhere else.
ContextOrigin: internalAPIBaseURL(),
} }
} }
+34 -2
View File
@@ -4,6 +4,8 @@ import (
"archive/tar" "archive/tar"
"compress/gzip" "compress/gzip"
"context" "context"
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
@@ -15,6 +17,8 @@ import (
"strings" "strings"
"syscall" "syscall"
"time" "time"
"felis.lolicon.best/internal/build"
) )
// cmdFetchContext is the in-Pod entrypoint the build Job's context-fetch // cmdFetchContext is the in-Pod entrypoint the build Job's context-fetch
@@ -41,9 +45,14 @@ func cmdFetchContext(args []string, _, stderr io.Writer) int {
fs.SetOutput(stderr) fs.SetOutput(stderr)
url := fs.String("url", "", "internal-face URL of the submission's build-context tarball") url := fs.String("url", "", "internal-face URL of the submission's build-context tarball")
out := fs.String("out", "/context", "directory to extract the build context into") out := fs.String("out", "/context", "directory to extract the build context into")
want := fs.String("sha256", "", "refuse the context unless the tarball's sha256 is this lowercase hex digest")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return 2 return 2
} }
if *want != "" && !build.IsSHA256Hex(*want) {
fmt.Fprintf(stderr, "felis fetch-context: --sha256 %q is not a lowercase hex sha256\n", *want)
return 2
}
if *url == "" { if *url == "" {
fmt.Fprintln(stderr, "felis fetch-context: --url is required") fmt.Fprintln(stderr, "felis fetch-context: --url is required")
return 2 return 2
@@ -66,7 +75,12 @@ func cmdFetchContext(args []string, _, stderr io.Writer) int {
// No overall client timeout: a legitimate modpack context can be large and the // No overall client timeout: a legitimate modpack context can be large and the
// Job's activeDeadlineSeconds is the real bound. The header timeout catches a // Job's activeDeadlineSeconds is the real bound. The header timeout catches a
// wedged endpoint without capping a healthy download. // wedged endpoint without capping a healthy download.
client := &http.Client{Transport: &http.Transport{ResponseHeaderTimeout: time.Minute}} // Redirects are refused: the request carries the service token, and the
// internal face never redirects, so a 3xx is someone steering the token.
client := &http.Client{
Transport: &http.Transport{ResponseHeaderTimeout: time.Minute},
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
resp, err := fetchContextWithRetry(ctx, client, *url, token, stderr) resp, err := fetchContextWithRetry(ctx, client, *url, token, stderr)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "felis fetch-context: %v\n", err) fmt.Fprintf(stderr, "felis fetch-context: %v\n", err)
@@ -74,10 +88,28 @@ func cmdFetchContext(args []string, _, stderr io.Writer) int {
} }
defer resp.Body.Close() defer resp.Body.Close()
if err := extractTarGz(resp.Body, *out); err != nil { h := sha256.New()
body := io.TeeReader(resp.Body, h)
if err := extractTarGz(body, *out); err != nil {
fmt.Fprintf(stderr, "felis fetch-context: %v\n", err) fmt.Fprintf(stderr, "felis fetch-context: %v\n", err)
return 1 return 1
} }
if *want == "" {
return 0
}
// The tar end marker comes before the gzip trailer and whatever follows it,
// so read to EOF: the digest must cover every byte the blob holds. The blob
// itself is size-capped at upload, which bounds this read.
if _, err := io.Copy(io.Discard, io.LimitReader(body, maxContextBytes)); err != nil {
fmt.Fprintf(stderr, "felis fetch-context: %v\n", err)
return 1
}
if got := hex.EncodeToString(h.Sum(nil)); got != *want {
// The init container failing is what keeps Kaniko from ever starting on
// the extracted tree.
fmt.Fprintf(stderr, "felis fetch-context: the context's sha256 is %s, the approved digest is %s: it changed after approval; refusing to build\n", got, *want)
return 1
}
return 0 return 0
} }
+65
View File
@@ -4,6 +4,8 @@ import (
"archive/tar" "archive/tar"
"bytes" "bytes"
"compress/gzip" "compress/gzip"
"crypto/sha256"
"encoding/hex"
"io" "io"
"net" "net"
"net/http" "net/http"
@@ -187,6 +189,69 @@ func TestCmdFetchContextFetchAndExtract(t *testing.T) {
} }
} }
// With --sha256 the fetch refuses any bytes but the approved ones, including
// a tarball that extracts cleanly: that is exactly the context an uploader
// swapped in after the review.
func TestCmdFetchContextChecksDigest(t *testing.T) {
body := tgzBody(t, tarEntry{name: "Dockerfile", body: "FROM scratch\n"})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(body)
}))
defer srv.Close()
t.Setenv("FELIS_SERVICE_TOKEN", "test-token")
sum := sha256.Sum256(body)
good := hex.EncodeToString(sum[:])
args := func(digest string) []string {
return []string{"--url=" + srv.URL + "/sub-1/context", "--out=" + t.TempDir(), "--sha256=" + digest}
}
if code := cmdFetchContext(args(good), io.Discard, io.Discard); code != 0 {
t.Fatalf("matching digest exit = %d, want 0", code)
}
var stderr bytes.Buffer
other := strings.Repeat("0", 64)
if code := cmdFetchContext(args(other), io.Discard, &stderr); code != 1 || !strings.Contains(stderr.String(), "changed after approval") {
t.Fatalf("mismatched digest exit = %d, stderr %q; want 1 naming the change", code, stderr.String())
}
stderr.Reset()
if code := cmdFetchContext(args("ABC"), io.Discard, &stderr); code != 2 {
t.Fatalf("malformed digest exit = %d, want 2 (stderr %q)", code, stderr.String())
}
// Bytes after the tar end marker still count: appending to an approved blob
// must change what the fetch accepts.
padded := append(append([]byte{}, body...), "trailing"...)
srvPadded := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(padded)
}))
defer srvPadded.Close()
if code := cmdFetchContext([]string{"--url=" + srvPadded.URL + "/c", "--out=" + t.TempDir(), "--sha256=" + good}, io.Discard, io.Discard); code != 1 {
t.Fatalf("padded blob exit = %d, want 1", code)
}
}
// The request carries the service token, so a redirect is a failure: the token
// never follows it to another host (build-supply-chain-13).
func TestCmdFetchContextRefusesRedirects(t *testing.T) {
var leaked bool
elsewhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
leaked = true
}))
defer elsewhere.Close()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, elsewhere.URL+"/steal", http.StatusFound)
}))
defer srv.Close()
t.Setenv("FELIS_SERVICE_TOKEN", "test-token")
var stderr bytes.Buffer
if code := cmdFetchContext([]string{"--url=" + srv.URL + "/c", "--out=" + t.TempDir()}, io.Discard, &stderr); code != 1 {
t.Fatalf("redirect exit = %d, want 1 (stderr %q)", code, stderr.String())
}
if leaked {
t.Fatal("the fetch followed the redirect")
}
}
// A body that is not a gzip tarball must fail the extraction rather than produce // A body that is not a gzip tarball must fail the extraction rather than produce
// an empty (or partial) context Kaniko would then try to build. // an empty (or partial) context Kaniko would then try to build.
func TestExtractTarGzRejectsNonGzip(t *testing.T) { func TestExtractTarGzRejectsNonGzip(t *testing.T) {
+20
View File
@@ -572,6 +572,7 @@ approved-but-hostile Dockerfile and the node is the pod around it:
| Layer | What it does | Where | | Layer | What it does | Where |
|---|---|---| |---|---|---|
| Admin approval | Nothing builds until an administrator approves the submission | submit lane | | Admin approval | Nothing builds until an administrator approves the submission | submit lane |
| Reviewed bytes | the approval names the context's sha256; a re-upload after review fails the approval, and the build refuses any other bytes | submit lane, `felis fetch-context` |
| Weak identity | `felis-build` SA, no Role anywhere, no token mounted | §8b | | Weak identity | `felis-build` SA, no Role anywhere, no token mounted | §8b |
| Egress lock | `felis-build-egress`: cluster DNS, the registry, the api internal face, nothing else | §8c | | Egress lock | `felis-build-egress`: cluster DNS, the registry, the api internal face, nothing else | §8c |
| Egress gate | first init container; holds the pod until the lock is enforced for it | `felis egress-gate` | | Egress gate | first init container; holds the pod until the lock is enforced for it | `felis egress-gate` |
@@ -583,6 +584,25 @@ approved-but-hostile Dockerfile and the node is the pod around it:
| Resources | CPU, memory and ephemeral-storage limits per container; `activeDeadlineSeconds`; the context extraction stops at 4 GiB or 200 000 entries | jobspec, `felis fetch-context` | | Resources | CPU, memory and ephemeral-storage limits per container; `activeDeadlineSeconds`; the context extraction stops at 4 GiB or 200 000 entries | jobspec, `felis fetch-context` |
| Namespace backstop | `felis-build-limits` LimitRange gives any container without limits 1 CPU / 1 GiB / 1 GiB disk | bundle | | Namespace backstop | `felis-build-limits` LimitRange gives any container without limits 1 CPU / 1 GiB / 1 GiB disk | bundle |
**Reviewed bytes.** Every upload records the sha256 of the archive, and the
review page shows it. The context download carries the same value in the
`X-Felis-Context-Sha256` header; the API cuts the transfer off if the stored
bytes no longer match it. Approving sends that digest back as
`expected_digest`, and the approval fails with `409 context_changed` when the
submitter has uploaded again since: download and review the new upload. The
approved digest is pinned on the build, and `felis fetch-context --sha256`
hashes every byte it receives; a mismatch fails the build before Kaniko starts:
```
felis fetch-context: the context's sha256 is 3f…, the approved digest is 9a…: it changed after approval; refusing to build
```
The panel approves with the digest of the file it downloaded in the same
session. When the review happened elsewhere (a CLI download, another browser),
it approves with the digest the list shows, so compare that value with
`sha256sum` of the file you actually read. A submission uploaded before digests
were recorded cannot be approved until the submitter uploads it again.
**Egress gate.** The CNI programs a new pod's NetworkPolicy a moment after the **Egress gate.** The CNI programs a new pod's NetworkPolicy a moment after the
pod starts. On k3s (kube-router), a pod in `felis-build` could reach the internet pod starts. On k3s (kube-router), a pod in `felis-build` could reach the internet
and the Kubernetes API for its first ~0.7 s. `egress-gate` dials the Kubernetes and the Kubernetes API for its first ~0.7 s. `egress-gate` dials the Kubernetes
+5
View File
@@ -70,6 +70,11 @@ func withRecover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() { defer func() {
if rec := recover(); rec != nil { if rec := recover(); rec != nil {
if rec == http.ErrAbortHandler {
// A deliberate abort of a committed response (see
// streamSubmissionContext): let net/http cut the connection.
panic(rec)
}
log.Printf("api: %s %s: panic (request_id=%s): %v\n%s", log.Printf("api: %s %s: panic (request_id=%s): %v\n%s",
r.Method, r.URL.Path, requestIDFromContext(r.Context()), rec, debug.Stack()) r.Method, r.URL.Path, requestIDFromContext(r.Context()), rec, debug.Stack())
writeError(w, r, newError(http.StatusInternalServerError, "panic", "internal error")) writeError(w, r, newError(http.StatusInternalServerError, "panic", "internal error"))
+81 -19
View File
@@ -2,8 +2,11 @@ package api
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"io" "io"
"log"
"net/http" "net/http"
"felis.lolicon.best/internal/build" "felis.lolicon.best/internal/build"
@@ -39,7 +42,9 @@ type SubmissionService interface {
List(ctx context.Context) ([]submit.Submission, error) List(ctx context.Context) ([]submit.Submission, error)
// Approve is the admin gate: it claims pending_review -> approved (CAS) and the // Approve is the admin gate: it claims pending_review -> approved (CAS) and the
// winner starts the SAME Trivy-gated build as an admin's direct build. // winner starts the SAME Trivy-gated build as an admin's direct build.
Approve(ctx context.Context, id, reviewedBy string) (*submit.Submission, error) // expectedDigest is the context sha256 the reviewer inspected; a row whose
// context has since been replaced refuses with ErrContextChanged.
Approve(ctx context.Context, id, reviewedBy, expectedDigest string) (*submit.Submission, error)
// Reject is the admin's other verdict: pending_review -> rejected with a // Reject is the admin's other verdict: pending_review -> rejected with a
// required reason; it starts no build. // required reason; it starts no build.
Reject(ctx context.Context, id, reviewedBy, reason string) (*submit.Submission, error) Reject(ctx context.Context, id, reviewedBy, reason string) (*submit.Submission, error)
@@ -54,7 +59,9 @@ type SubmissionService interface {
// context-fetch route: the build Pod's initContainer cannot mount the uploads // context-fetch route: the build Pod's initContainer cannot mount the uploads
// PVC across namespaces and holds no object-store credentials, so it streams // PVC across namespaces and holds no object-store credentials, so it streams
// the blob from the API over the service-token-gated internal face instead. // the blob from the API over the service-token-gated internal face instead.
OpenContext(ctx context.Context, id string) (io.ReadCloser, error) // The string is the sha256 the row records for the blob ("" when none was
// recorded); the routes refuse to finish a stream that does not match it.
OpenContext(ctx context.Context, id string) (io.ReadCloser, string, error)
} }
// createSubmissionRequest is the POST /me/submissions body. The user // createSubmissionRequest is the POST /me/submissions body. The user
@@ -81,6 +88,18 @@ type rejectSubmissionRequest struct {
Reason string `json:"reason"` Reason string `json:"reason"`
} }
// approveSubmissionRequest is the POST /submissions/{id}/approve body: the
// context digest the admin reviewed. The panel sends the digest of the bytes it
// downloaded (the X-Felis-Context-Sha256 header), or the listed one.
type approveSubmissionRequest struct {
ExpectedDigest string `json:"expected_digest"`
}
// contextDigestHeader carries the recorded sha256 on both context routes, so a
// reviewer can compare it with `sha256sum` and the panel can approve exactly the
// bytes it fetched.
const contextDigestHeader = "X-Felis-Context-Sha256"
// handleCreateSubmission records a new pending_review submission (app-tier). The // handleCreateSubmission records a new pending_review submission (app-tier). The
// submitter is the authenticated principal's id — never the body — so a user can // submitter is the authenticated principal's id — never the body — so a user can
// only ever file an upload under their own identity. // only ever file an upload under their own identity.
@@ -269,7 +288,14 @@ func (a *API) handleApproveSubmission(w http.ResponseWriter, r *http.Request) {
} }
p := principalFromContext(r.Context()) p := principalFromContext(r.Context())
id := r.PathValue("id") id := r.PathValue("id")
sub, err := a.Submissions.Approve(r.Context(), id, p.Email) var body approveSubmissionRequest
if r.ContentLength != 0 {
if err := decodeJSON(w, r, &body); err != nil {
writeError(w, r, err)
return
}
}
sub, err := a.Submissions.Approve(r.Context(), id, p.Email, body.ExpectedDigest)
if err != nil { if err != nil {
writeSubmitError(w, r, err) writeSubmitError(w, r, err)
return return
@@ -368,6 +394,9 @@ func writeSubmitError(w http.ResponseWriter, r *http.Request, err error) {
case errors.Is(err, submit.ErrAlreadyReviewed): case errors.Is(err, submit.ErrAlreadyReviewed):
writeError(w, r, newError(http.StatusConflict, "already_reviewed", writeError(w, r, newError(http.StatusConflict, "already_reviewed",
"submission has already been reviewed")) "submission has already been reviewed"))
case errors.Is(err, submit.ErrContextChanged):
writeError(w, r, newError(http.StatusConflict, "context_changed",
"the build context was uploaded again after it was reviewed; review the new upload before approving"))
case errors.Is(err, submit.ErrQuotaExceeded): case errors.Is(err, submit.ErrQuotaExceeded):
writeError(w, r, newError(http.StatusForbidden, "submission_quota_exceeded", writeError(w, r, newError(http.StatusForbidden, "submission_quota_exceeded",
"submission quota reached")) "submission quota reached"))
@@ -390,11 +419,11 @@ func writeSubmitError(w http.ResponseWriter, r *http.Request, err error) {
// fetcher extracts it under a zip-slip guard, and Kaniko treats the result as // fetcher extracts it under a zip-slip guard, and Kaniko treats the result as
// hostile regardless (spec §16). // hostile regardless (spec §16).
func (a *API) handleInternalSubmissionContext(w http.ResponseWriter, r *http.Request) { func (a *API) handleInternalSubmissionContext(w http.ResponseWriter, r *http.Request) {
rc, ok := a.openSubmissionContext(w, r) rc, digest, ok := a.openSubmissionContext(w, r)
if !ok { if !ok {
return return
} }
streamSubmissionContext(w, rc) streamSubmissionContext(w, r, rc, digest)
} }
// handleAdminSubmissionContext streams a submission's stored build-context // handleAdminSubmissionContext streams a submission's stored build-context
@@ -405,44 +434,77 @@ func (a *API) handleInternalSubmissionContext(w http.ResponseWriter, r *http.Req
// ones the build Pod fetches over the internal face; the attachment disposition // ones the build Pod fetches over the internal face; the attachment disposition
// makes the browser download the attacker-supplied archive, never render it. // makes the browser download the attacker-supplied archive, never render it.
func (a *API) handleAdminSubmissionContext(w http.ResponseWriter, r *http.Request) { func (a *API) handleAdminSubmissionContext(w http.ResponseWriter, r *http.Request) {
rc, ok := a.openSubmissionContext(w, r) rc, digest, ok := a.openSubmissionContext(w, r)
if !ok { if !ok {
return return
} }
w.Header().Set("Content-Disposition", `attachment; filename="context.tar.gz"`) w.Header().Set("Content-Disposition", `attachment; filename="context.tar.gz"`)
w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Type-Options", "nosniff")
a.audit(r, "submission.context.download", r.PathValue("id")) a.audit(r, "submission.context.download", r.PathValue("id"))
streamSubmissionContext(w, rc) streamSubmissionContext(w, r, rc, digest)
} }
// openSubmissionContext resolves the build-context blob named in the request // openSubmissionContext resolves the build-context blob named in the request
// path, mapping the submit-layer errors onto the shared submission statuses (a // path, mapping the submit-layer errors onto the shared submission statuses (a
// missing blob is 404, an unwired transport 503). On failure the error response // missing blob is 404, an unwired transport 503). On failure the error response
// is already written and the caller must return. // is already written and the caller must return.
func (a *API) openSubmissionContext(w http.ResponseWriter, r *http.Request) (io.ReadCloser, bool) { func (a *API) openSubmissionContext(w http.ResponseWriter, r *http.Request) (io.ReadCloser, string, bool) {
if a.Submissions == nil { if a.Submissions == nil {
writeError(w, r, errSubmissionsUnavailable) writeError(w, r, errSubmissionsUnavailable)
return nil, false return nil, "", false
} }
rc, err := a.Submissions.OpenContext(r.Context(), r.PathValue("id")) rc, digest, err := a.Submissions.OpenContext(r.Context(), r.PathValue("id"))
if err != nil { if err != nil {
writeSubmitError(w, r, err) writeSubmitError(w, r, err)
return nil, false return nil, "", false
} }
return rc, true return rc, digest, true
} }
// streamSubmissionContext copies the blob to w verbatim and closes it. The // streamSubmissionContext copies the blob to w and closes it. The caller must
// caller must have set every header already: the copy commits the response, so // have set every header already: the copy commits the response.
// a failure mid-stream can only truncate it. //
func streamSubmissionContext(w http.ResponseWriter, rc io.ReadCloser) { // With a recorded digest the final chunk is held back until the whole blob has
// been hashed. Bytes that do not match the row (a re-upload landed between the
// row read and the open) or a failed read abort the response instead of ending
// it cleanly, so neither the reviewer's download nor the build's fetch can take
// a prefix or a different blob for the recorded one.
func streamSubmissionContext(w http.ResponseWriter, r *http.Request, rc io.ReadCloser, digest string) {
defer rc.Close() defer rc.Close()
w.Header().Set("Content-Type", "application/gzip") w.Header().Set("Content-Type", "application/gzip")
if _, err := io.Copy(w, rc); err != nil { if digest == "" {
// The status is already committed; the client sees a truncated stream and // A context uploaded before digests were kept: nothing to check against.
// the fetch fails on size/extract, so there is nothing left to write here. _, _ = io.Copy(w, rc)
return return
} }
w.Header().Set(contextDigestHeader, digest)
h := sha256.New()
buf := make([]byte, 32<<10)
var held []byte
for {
n, err := rc.Read(buf)
if n > 0 {
if len(held) > 0 {
if _, werr := w.Write(held); werr != nil {
return
}
}
held = append(held[:0], buf[:n]...)
h.Write(buf[:n])
}
if err == io.EOF {
break
}
if err != nil {
log.Printf("api: submission %s context read failed mid-stream: %v", r.PathValue("id"), err)
panic(http.ErrAbortHandler)
}
}
if got := hex.EncodeToString(h.Sum(nil)); got != digest {
log.Printf("api: submission %s context hashes to %s, the row records %s; response aborted", r.PathValue("id"), got, digest)
panic(http.ErrAbortHandler)
}
_, _ = w.Write(held)
} }
// Compile-time proof that the production Manager satisfies the API interface. // Compile-time proof that the production Manager satisfies the API interface.
+70 -5
View File
@@ -2,11 +2,14 @@ package api
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/http/httptest"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -46,6 +49,9 @@ type fakeSubmissions struct {
openedID string openedID string
openBody string openBody string
openErr error openErr error
approvedDigest string
openDigest string
} }
func (f *fakeSubmissions) Create(_ context.Context, req submit.CreateRequest) (*submit.Submission, error) { func (f *fakeSubmissions) Create(_ context.Context, req submit.CreateRequest) (*submit.Submission, error) {
@@ -77,8 +83,8 @@ func (f *fakeSubmissions) List(_ context.Context) ([]submit.Submission, error) {
return f.listed, f.listErr return f.listed, f.listErr
} }
func (f *fakeSubmissions) Approve(_ context.Context, id, reviewedBy string) (*submit.Submission, error) { func (f *fakeSubmissions) Approve(_ context.Context, id, reviewedBy, expectedDigest string) (*submit.Submission, error) {
f.approvedID, f.approvedBy = id, reviewedBy f.approvedID, f.approvedBy, f.approvedDigest = id, reviewedBy, expectedDigest
if f.approveErr != nil { if f.approveErr != nil {
return nil, f.approveErr return nil, f.approveErr
} }
@@ -111,12 +117,12 @@ func (f *fakeSubmissions) Delete(_ context.Context, id string) (*submit.Submissi
// openErr injects the OpenContext outcome; the body recorder lets the internal // openErr injects the OpenContext outcome; the body recorder lets the internal
// route test assert byte-exact streaming and the 404 mapping. // route test assert byte-exact streaming and the 404 mapping.
func (f *fakeSubmissions) OpenContext(_ context.Context, id string) (io.ReadCloser, error) { func (f *fakeSubmissions) OpenContext(_ context.Context, id string) (io.ReadCloser, string, error) {
f.openedID = id f.openedID = id
if f.openErr != nil { if f.openErr != nil {
return nil, f.openErr return nil, "", f.openErr
} }
return io.NopCloser(strings.NewReader(f.openBody)), nil return io.NopCloser(strings.NewReader(f.openBody)), f.openDigest, nil
} }
// appSubAPI wires a submissions service behind an ordinary user principal (the // appSubAPI wires a submissions service behind an ordinary user principal (the
@@ -483,6 +489,31 @@ func TestApproveSubmission(t *testing.T) {
} }
} }
// The digest the reviewer inspected travels in the body, and a context replaced
// since then is a 409 the panel can act on.
func TestApproveSubmissionForwardsTheReviewedDigest(t *testing.T) {
fs := &fakeSubmissions{}
digest := strings.Repeat("ab", 32)
w := do(adminSubAPI(fs).ExternalHandler(), "POST", "/api/v1/submissions/sub-9/approve",
`{"expected_digest":"`+digest+`"}`, nil)
if w.Code != http.StatusOK || fs.approvedDigest != digest {
t.Fatalf("code = %d, forwarded digest %q (%s)", w.Code, fs.approvedDigest, w.Body.String())
}
fs = &fakeSubmissions{approveErr: submit.ErrContextChanged}
w = do(adminSubAPI(fs).ExternalHandler(), "POST", "/api/v1/submissions/sub-9/approve",
`{"expected_digest":"`+digest+`"}`, nil)
if w.Code != http.StatusConflict || decodeErr(t, w) != "context_changed" {
t.Fatalf("code = %d body %s, want 409 context_changed", w.Code, w.Body.String())
}
w = do(adminSubAPI(&fakeSubmissions{}).ExternalHandler(), "POST", "/api/v1/submissions/sub-9/approve",
`{"digest":"x"}`, nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("unknown field code = %d, want 400", w.Code)
}
}
func TestApproveSubmissionAlreadyReviewedIs409(t *testing.T) { func TestApproveSubmissionAlreadyReviewedIs409(t *testing.T) {
fs := &fakeSubmissions{approveErr: submit.ErrAlreadyReviewed} fs := &fakeSubmissions{approveErr: submit.ErrAlreadyReviewed}
api := adminSubAPI(fs) api := adminSubAPI(fs)
@@ -636,6 +667,40 @@ func TestAdminSubmissionContextRoute(t *testing.T) {
} }
}) })
t.Run("names the recorded digest", func(t *testing.T) {
body := "\x1f\x8b\x08\x00blob"
sum := sha256.Sum256([]byte(body))
fs := &fakeSubmissions{openBody: body, openDigest: hex.EncodeToString(sum[:])}
w := do(adminSubAPI(fs).ExternalHandler(), "GET", "/api/v1/submissions/sub-7/context", "", nil)
if w.Code != http.StatusOK || w.Body.String() != body {
t.Fatalf("code = %d body %q", w.Code, w.Body.String())
}
if got := w.Header().Get("X-Felis-Context-Sha256"); got != fs.openDigest {
t.Fatalf("digest header = %q, want %q", got, fs.openDigest)
}
})
// Bytes that are not the recorded ones must not arrive as a complete download:
// the reviewer would inspect a blob the approval does not name.
t.Run("aborts a blob that does not match", func(t *testing.T) {
big := "\x1f\x8b" + strings.Repeat("x", 100<<10)
fs := &fakeSubmissions{openBody: big, openDigest: strings.Repeat("0", 64)}
srv := httptest.NewServer(adminSubAPI(fs).ExternalHandler())
defer srv.Close()
resp, err := http.Get(srv.URL + "/api/v1/submissions/sub-7/context")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
got, err := io.ReadAll(resp.Body)
if err == nil {
t.Fatalf("read %d bytes cleanly; want the response aborted", len(got))
}
if len(got) >= len(big) {
t.Fatalf("received all %d bytes before the abort", len(got))
}
})
t.Run("missing blob is 404", func(t *testing.T) { t.Run("missing blob is 404", func(t *testing.T) {
fs := &fakeSubmissions{openErr: fmt.Errorf("%w: gone", submit.ErrBlobNotFound)} fs := &fakeSubmissions{openErr: fmt.Errorf("%w: gone", submit.ErrBlobNotFound)}
w := do(adminSubAPI(fs).ExternalHandler(), "GET", "/api/v1/submissions/sub-7/context", "", nil) w := do(adminSubAPI(fs).ExternalHandler(), "GET", "/api/v1/submissions/sub-7/context", "", nil)
+16
View File
@@ -129,6 +129,11 @@ type Request struct {
// ContextRef locates the uploaded tar.gz context in object storage / a PVC // ContextRef locates the uploaded tar.gz context in object storage / a PVC
// (spec §17: Kaniko pulls it; Git context is intentionally not supported). // (spec §17: Kaniko pulls it; Git context is intentionally not supported).
ContextRef string ContextRef string
// ContextDigest is the lowercase hex sha256 of the context tarball an admin
// approved. When set, the context-fetch initContainer refuses any other bytes,
// so a context replaced after review never reaches Kaniko. It needs an
// http(s) ContextRef: a ref Kaniko fetches itself has no fetch step to check.
ContextDigest string
// BaseImage is the resolved FROM, recorded for audit only — it is NOT a hard // BaseImage is the resolved FROM, recorded for audit only — it is NOT a hard
// gate (spec §16: base FROM is not hard-gated; the scan + egress lock cover // gate (spec §16: base FROM is not hard-gated; the scan + egress lock cover
// poisoned bases). // poisoned bases).
@@ -152,6 +157,10 @@ type Build struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"` FinishedAt *time.Time `json:"finished_at,omitempty"`
// ContextDigest is Request.ContextDigest, kept on the row as the audit record
// of which bytes the build was allowed to consume.
ContextDigest string `json:"context_digest,omitempty"`
} }
// Image mirrors an image_whitelist row (spec §6): the dynamic, auditable image // Image mirrors an image_whitelist row (spec §6): the dynamic, auditable image
@@ -257,6 +266,11 @@ type Config struct {
// RuntimeClass runs build pods under a sandbox RuntimeClass (gVisor, Kata) // RuntimeClass runs build pods under a sandbox RuntimeClass (gVisor, Kata)
// when set. The class must exist on the cluster. // when set. The class must exist on the cluster.
RuntimeClass string RuntimeClass string
// ContextOrigin is the scheme://host[:port] of the platform's internal API
// face, the only host an http(s) ContextRef may name: the fetch step presents
// the service token to it. Empty refuses every http(s) context.
ContextOrigin string
} }
// Values of Config.UserNamespaces. // Values of Config.UserNamespaces.
@@ -375,6 +389,7 @@ func (b *Builder) Submit(ctx context.Context, req Request) (*Build, error) {
RequestedBy: req.RequestedBy, RequestedBy: req.RequestedBy,
CreatedAt: now, CreatedAt: now,
} }
bld.ContextDigest = req.ContextDigest
if err := b.Store.CreateBuild(ctx, bld); err != nil { if err := b.Store.CreateBuild(ctx, bld); err != nil {
return nil, err return nil, err
} }
@@ -408,6 +423,7 @@ func (b *Builder) jobParams(bld *Build, cfg Config) JobParams {
BuildID: bld.ID, BuildID: bld.ID,
ImageRef: bld.ImageRef, ImageRef: bld.ImageRef,
ContextRef: bld.ContextRef, ContextRef: bld.ContextRef,
ContextDigest: bld.ContextDigest,
Namespace: cfg.Namespace, Namespace: cfg.Namespace,
ServiceAccount: cfg.ServiceAccount, ServiceAccount: cfg.ServiceAccount,
RegistryURL: cfg.RegistryURL, RegistryURL: cfg.RegistryURL,
+55
View File
@@ -221,6 +221,61 @@ func TestSubmitRejectsExternalRegistryTarget(t *testing.T) {
// The platform's own images and the scanner's DB mirrors live under felis/ and // The platform's own images and the scanner's DB mirrors live under felis/ and
// mirror/; the registry gate refuses the build principal there, and Validate turns // mirror/; the registry gate refuses the build principal there, and Validate turns
// that into a 400 before a Job spends minutes building an image it cannot push. // that into a 400 before a Job spends minutes building an image it cannot push.
// A context digest must be a real sha256 and needs a fetch step to enforce it.
func TestValidateContextDigest(t *testing.T) {
cfg := Config{RegistryURL: "registry.felis.svc:5000", ContextOrigin: "http://felis-api-internal:8081"}
req := Request{ImageRef: "registry.felis.svc:5000/user-uploads/sub-1:latest", Dockerfile: "FROM scratch",
ContextRef: "http://felis-api-internal:8081/api/v1/internal/submissions/sub-1/context", ContextDigest: strings.Repeat("e", 64)}
if err := Validate(req, cfg); err != nil {
t.Fatalf("valid digest: %v", err)
}
bad := req
bad.ContextDigest = strings.Repeat("E", 64)
if err := Validate(bad, cfg); err == nil {
t.Fatal("an uppercase digest was accepted")
}
bad = req
bad.ContextRef = "s3://bucket/ctx.tar.gz"
if err := Validate(bad, cfg); err == nil {
t.Fatal("a digest on a context Kaniko fetches itself was accepted")
}
}
// The fetch step hands the service token to the context URL's host, so an
// http(s) context must be an upload on the internal face (build-supply-chain-13).
func TestValidateConfinesHTTPContextsToTheInternalFace(t *testing.T) {
cfg := Config{RegistryURL: "registry.felis.svc:5000", ContextOrigin: "http://felis-api-internal.felis.svc.cluster.local:8081/"}
req := goodRequest()
for _, ref := range []string{
"http://felis-api-internal.felis.svc.cluster.local:8081/api/v1/internal/submissions/sub-1/context",
"HTTP://Felis-API-Internal.felis.svc.cluster.local:8081/api/v1/internal/submissions/sub-1/context",
"tar://contexts/abc.tar.gz",
} {
req.ContextRef = ref
if err := Validate(req, cfg); err != nil {
t.Errorf("Validate(%q) = %v, want accepted", ref, err)
}
}
for _, ref := range []string{
"https://attacker.example/api/v1/internal/submissions/sub-1/context",
"http://felis-api-internal.felis.svc.cluster.local:8082/api/v1/internal/submissions/sub-1/context",
"https://felis-api-internal.felis.svc.cluster.local:8081/api/v1/internal/submissions/sub-1/context",
"http://felis-api-internal.felis.svc.cluster.local:8081/api/v1/internal/servers",
"http://felis-api-internal.felis.svc.cluster.local:8081/api/v1/internal/submissions/../x/context",
"http://felis-api-internal.felis.svc.cluster.local:8081/api/v1/internal/submissions/sub-1/context?next=https://x",
"http://u:[email protected]:8081/api/v1/internal/submissions/sub-1/context",
} {
req.ContextRef = ref
if err := Validate(req, cfg); !errors.Is(err, ErrInvalid) {
t.Errorf("Validate(%q) = %v, want ErrInvalid", ref, err)
}
}
req.ContextRef = "http://felis-api-internal.felis.svc.cluster.local:8081/api/v1/internal/submissions/sub-1/context"
if err := Validate(req, Config{RegistryURL: "registry.felis.svc:5000"}); !errors.Is(err, ErrInvalid) {
t.Errorf("without an internal face configured an http context was accepted: %v", err)
}
}
func TestValidateRejectsReservedRepos(t *testing.T) { func TestValidateRejectsReservedRepos(t *testing.T) {
cfg := Config{RegistryURL: "registry.felis.svc:5000"} cfg := Config{RegistryURL: "registry.felis.svc:5000"}
for _, ref := range []string{ for _, ref := range []string{
+20 -11
View File
@@ -100,9 +100,12 @@ const buildJobTTL = 7 * 24 * time.Hour
// Build + Config by the Builder; jobspec is a pure function of them so the // Build + Config by the Builder; jobspec is a pure function of them so the
// security-critical Job shape is unit-tested without a cluster. // security-critical Job shape is unit-tested without a cluster.
type JobParams struct { type JobParams struct {
BuildID string BuildID string
ImageRef string ImageRef string
ContextRef string ContextRef string
// ContextDigest, when set, is passed to the fetch container, which refuses a
// context whose sha256 differs (see Request.ContextDigest).
ContextDigest string
Namespace string Namespace string
ServiceAccount string ServiceAccount string
RegistryURL string RegistryURL string
@@ -269,7 +272,7 @@ func BuildJob(p JobParams) (*batchv1.Job, error) {
SizeLimit: quantityPtr(imageSizeLimit), SizeLimit: quantityPtr(imageSizeLimit),
}}, }},
}} }}
if isHTTPContextRef(p.ContextRef) { if IsHTTPContextRef(p.ContextRef) {
contextPath = contextMountPath contextPath = contextMountPath
// The fetch container runs as root while Kaniko keeps the image default // The fetch container runs as root while Kaniko keeps the image default
// (also root): Kaniko re-copies the Dockerfile out of the context and // (also root): Kaniko re-copies the Dockerfile out of the context and
@@ -287,11 +290,7 @@ func BuildJob(p JobParams) (*batchv1.Job, error) {
fetch := corev1.Container{ fetch := corev1.Container{
Name: ContainerFetch, Name: ContainerFetch,
Image: p.FelisImage, Image: p.FelisImage,
Args: []string{ Args: fetchArgs(p),
"fetch-context",
"--url=" + p.ContextRef,
"--out=" + contextMountPath,
},
// The internal face is service-token gated, and the token is read from a // The internal face is service-token gated, and the token is read from a
// Secret the installer materializes in THIS namespace (secretKeyRef is // Secret the installer materializes in THIS namespace (secretKeyRef is
// namespace-local). It is mounted into this initContainer only: the Kaniko // namespace-local). It is mounted into this initContainer only: the Kaniko
@@ -461,10 +460,20 @@ func withDisk(limits corev1.ResourceList, d diskBounds) corev1.ResourceRequireme
return corev1.ResourceRequirements{Limits: lim, Requests: req} return corev1.ResourceRequirements{Limits: lim, Requests: req}
} }
// isHTTPContextRef reports whether ref is an http(s) URL — the shape the submit // fetchArgs is the context-fetch container's argv. The digest flag rides along
// only when the build pins one; admin builds from a URL they supplied have none.
func fetchArgs(p JobParams) []string {
args := []string{"fetch-context", "--url=" + p.ContextRef, "--out=" + contextMountPath}
if p.ContextDigest != "" {
args = append(args, "--sha256="+p.ContextDigest)
}
return args
}
// IsHTTPContextRef reports whether ref is an http(s) URL — the shape the submit
// lane derives when the API is the blob transport — i.e. a context only the // lane derives when the API is the blob transport — i.e. a context only the
// fetch initContainer can turn into a local path for Kaniko. // fetch initContainer can turn into a local path for Kaniko.
func isHTTPContextRef(ref string) bool { func IsHTTPContextRef(ref string) bool {
return strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") return strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://")
} }
+31
View File
@@ -527,6 +527,37 @@ func TestBuildJobGatesEgressFirst(t *testing.T) {
} }
} }
// An approved submission pins its context digest on the fetch container, which
// then refuses any other bytes; a build without one fetches unpinned.
func TestBuildJobPinsContextDigest(t *testing.T) {
p := sampleJobParams()
p.ContextRef = "http://felis-api-internal.felis.svc.cluster.local:8081/ctx"
fetchArgsOf := func(p JobParams) []string {
t.Helper()
job, err := BuildJob(p)
if err != nil {
t.Fatalf("BuildJob: %v", err)
}
for _, c := range job.Spec.Template.Spec.InitContainers {
if c.Name == ContainerFetch {
return c.Args
}
}
t.Fatal("no fetch container")
return nil
}
for _, a := range fetchArgsOf(p) {
if strings.HasPrefix(a, "--sha256") {
t.Fatalf("unpinned build carries %q", a)
}
}
p.ContextDigest = strings.Repeat("d", 64)
args := fetchArgsOf(p)
if args[len(args)-1] != "--sha256="+p.ContextDigest {
t.Fatalf("fetch args = %v, want the digest pinned", args)
}
}
// The pod runs under RuntimeDefault seccomp always, and in a user namespace or // The pod runs under RuntimeDefault seccomp always, and in a user namespace or
// a sandbox runtime when the install asks for them. // a sandbox runtime when the install asks for them.
func TestBuildJobSandboxing(t *testing.T) { func TestBuildJobSandboxing(t *testing.T) {
+11 -7
View File
@@ -21,17 +21,17 @@ func NewPGStore(db *sql.DB) *PGStore { return &PGStore{db: db} }
func (s *PGStore) CreateBuild(ctx context.Context, b *Build) error { func (s *PGStore) CreateBuild(ctx context.Context, b *Build) error {
const q = `INSERT INTO image_builds const q = `INSERT INTO image_builds
(id, image_ref, status, dockerfile, context_ref, base_image, requested_by, created_at) (id, image_ref, status, dockerfile, context_ref, base_image, requested_by, created_at, context_digest)
VALUES ($1, $2, $3, $4, NULLIF($5, ''), NULLIF($6, ''), $7, $8)` VALUES ($1, $2, $3, $4, NULLIF($5, ''), NULLIF($6, ''), $7, $8, NULLIF($9, ''))`
_, err := s.db.ExecContext(ctx, q, _, err := s.db.ExecContext(ctx, q,
b.ID, b.ImageRef, string(b.Status), b.Dockerfile, b.ContextRef, b.BaseImage, b.ID, b.ImageRef, string(b.Status), b.Dockerfile, b.ContextRef, b.BaseImage,
b.RequestedBy, b.CreatedAt) b.RequestedBy, b.CreatedAt, b.ContextDigest)
return err return err
} }
func (s *PGStore) GetBuild(ctx context.Context, id string) (*Build, error) { func (s *PGStore) GetBuild(ctx context.Context, id string) (*Build, error) {
const q = `SELECT id, image_ref, status, dockerfile, context_ref, base_image, const q = `SELECT id, image_ref, status, dockerfile, context_ref, base_image,
requested_by, job_name, log_ref, error, created_at, finished_at requested_by, job_name, log_ref, error, created_at, finished_at, context_digest
FROM image_builds WHERE id = $1` FROM image_builds WHERE id = $1`
return s.scanBuild(s.db.QueryRowContext(ctx, q, id)) return s.scanBuild(s.db.QueryRowContext(ctx, q, id))
} }
@@ -42,9 +42,10 @@ func (s *PGStore) scanBuild(row *sql.Row) (*Build, error) {
status string status string
ctxRef, base, jobName, logRef, eMsg sql.NullString ctxRef, base, jobName, logRef, eMsg sql.NullString
finished sql.NullTime finished sql.NullTime
digest sql.NullString
) )
switch err := row.Scan(&b.ID, &b.ImageRef, &status, &b.Dockerfile, &ctxRef, &base, switch err := row.Scan(&b.ID, &b.ImageRef, &status, &b.Dockerfile, &ctxRef, &base,
&b.RequestedBy, &jobName, &logRef, &eMsg, &b.CreatedAt, &finished); { &b.RequestedBy, &jobName, &logRef, &eMsg, &b.CreatedAt, &finished, &digest); {
case err == sql.ErrNoRows: case err == sql.ErrNoRows:
return nil, ErrNotFound return nil, ErrNotFound
case err != nil: case err != nil:
@@ -56,6 +57,7 @@ func (s *PGStore) scanBuild(row *sql.Row) (*Build, error) {
b.JobName = jobName.String b.JobName = jobName.String
b.LogRef = logRef.String b.LogRef = logRef.String
b.Error = eMsg.String b.Error = eMsg.String
b.ContextDigest = digest.String
if finished.Valid { if finished.Valid {
t := finished.Time t := finished.Time
b.FinishedAt = &t b.FinishedAt = &t
@@ -91,7 +93,7 @@ func (s *PGStore) FinishBuild(ctx context.Context, id string, status Status, err
func (s *PGStore) ListUnfinishedBuilds(ctx context.Context) ([]Build, error) { func (s *PGStore) ListUnfinishedBuilds(ctx context.Context) ([]Build, error) {
const q = `SELECT id, image_ref, status, dockerfile, context_ref, base_image, const q = `SELECT id, image_ref, status, dockerfile, context_ref, base_image,
requested_by, job_name, log_ref, error, created_at, finished_at requested_by, job_name, log_ref, error, created_at, finished_at, context_digest
FROM image_builds WHERE status IN ('pending', 'building') ORDER BY created_at ASC` FROM image_builds WHERE status IN ('pending', 'building') ORDER BY created_at ASC`
rows, err := s.db.QueryContext(ctx, q) rows, err := s.db.QueryContext(ctx, q)
if err != nil { if err != nil {
@@ -105,9 +107,10 @@ func (s *PGStore) ListUnfinishedBuilds(ctx context.Context) ([]Build, error) {
status string status string
ctxRef, base, jobName, logRef, eMsg sql.NullString ctxRef, base, jobName, logRef, eMsg sql.NullString
finished sql.NullTime finished sql.NullTime
digest sql.NullString
) )
if err := rows.Scan(&b.ID, &b.ImageRef, &status, &b.Dockerfile, &ctxRef, &base, if err := rows.Scan(&b.ID, &b.ImageRef, &status, &b.Dockerfile, &ctxRef, &base,
&b.RequestedBy, &jobName, &logRef, &eMsg, &b.CreatedAt, &finished); err != nil { &b.RequestedBy, &jobName, &logRef, &eMsg, &b.CreatedAt, &finished, &digest); err != nil {
return nil, err return nil, err
} }
b.Status = Status(status) b.Status = Status(status)
@@ -116,6 +119,7 @@ func (s *PGStore) ListUnfinishedBuilds(ctx context.Context) ([]Build, error) {
b.JobName = jobName.String b.JobName = jobName.String
b.LogRef = logRef.String b.LogRef = logRef.String
b.Error = eMsg.String b.Error = eMsg.String
b.ContextDigest = digest.String
if finished.Valid { if finished.Valid {
t := finished.Time t := finished.Time
b.FinishedAt = &t b.FinishedAt = &t
+58
View File
@@ -2,6 +2,7 @@ package build
import ( import (
"fmt" "fmt"
"net/url"
"regexp" "regexp"
"strings" "strings"
@@ -37,9 +38,66 @@ func Validate(req Request, cfg Config) error {
if strings.TrimSpace(req.ContextRef) == "" { if strings.TrimSpace(req.ContextRef) == "" {
return invalidf("context reference is required") return invalidf("context reference is required")
} }
if IsHTTPContextRef(req.ContextRef) {
if err := validateContextURL(req.ContextRef, cfg.ContextOrigin); err != nil {
return err
}
}
if req.ContextDigest != "" {
if !IsSHA256Hex(req.ContextDigest) {
return invalidf("context digest %q is not a lowercase hex sha256", req.ContextDigest)
}
if !IsHTTPContextRef(req.ContextRef) {
return invalidf("a context digest needs an http(s) context reference, whose fetch step checks it")
}
}
return nil return nil
} }
// internalContextPathRE is the one internal-face route a build fetches from.
var internalContextPathRE = regexp.MustCompile(`^/api/v1/internal/submissions/[A-Za-z0-9_-]+/context$`)
// validateContextURL admits an http(s) context only when it is an uploaded
// submission on the platform's internal face. The fetch step sends the service
// token to whatever host the URL names, so an admin-typed URL pointing anywhere
// else would hand that token to a stranger.
func validateContextURL(ref, origin string) error {
if origin == "" {
return invalidf("an http(s) context reference is fetched from the platform's internal API, which this builder is not configured with")
}
u, err := url.Parse(ref)
if err != nil || u.User != nil || u.RawQuery != "" || u.Fragment != "" ||
URLOrigin(ref) != URLOrigin(origin) || !internalContextPathRE.MatchString(u.Path) {
return invalidf("an http(s) context reference must be an uploaded submission on the internal API (%s/api/v1/internal/submissions/<id>/context)",
strings.TrimRight(origin, "/"))
}
return nil
}
// URLOrigin returns the lowercased scheme://host[:port] of raw, or "" when raw
// is not an absolute http(s) URL.
func URLOrigin(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return ""
}
return strings.ToLower(u.Scheme + "://" + u.Host)
}
// IsSHA256Hex reports whether s is a lowercase hex-encoded sha256 digest, the
// form the submit lane records and the context fetcher compares against.
func IsSHA256Hex(s string) bool {
if len(s) != 64 {
return false
}
for _, c := range s {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return false
}
}
return true
}
// ValidateImageRef checks a bare image reference (used by external admission, // ValidateImageRef checks a bare image reference (used by external admission,
// where there is no registry-target constraint beyond well-formedness). A // where there is no registry-target constraint beyond well-formedness). A
// whitelist entry may be a tag wildcard ("registry/foo:*", a legitimate // whitelist entry may be a tag wildcard ("registry/foo:*", a legitimate
+23 -4
View File
@@ -1153,14 +1153,31 @@ func TestSubmitStoreContract(t *testing.T) {
t.Fatalf("ListSubmissions: (%v, %v), want the submission present", all, err) t.Fatalf("ListSubmissions: (%v, %v), want the submission present", all, err)
} }
// The upload records its digest; the approval CAS names it, so an approval of
// a replaced context loses while the row stays pending.
reviewed, swapped := strings.Repeat("a", 64), strings.Repeat("b", 64)
if ok, err := s.SetContextDigest(ctx, id, reviewed); err != nil || !ok {
t.Fatalf("SetContextDigest = (%v, %v), want (true, nil)", ok, err)
}
if got, _ := s.GetSubmission(ctx, id); got.ContextSHA256 != reviewed {
t.Fatalf("context_sha256 = %q, want %q", got.ContextSHA256, reviewed)
}
if ok, err := s.ApproveSubmission(ctx, id, "[email protected]", "registry/x:1", swapped, now); err != nil || ok {
t.Fatalf("approve naming another digest = (%v, %v), want (false, nil)", ok, err)
}
// The approval CAS: exactly one winner, and only from pending_review. // The approval CAS: exactly one winner, and only from pending_review.
ok, err := s.ApproveSubmission(ctx, id, "[email protected]", "registry.felis.svc:5000/user-uploads/"+id+":latest", now) ok, err := s.ApproveSubmission(ctx, id, "[email protected]", "registry.felis.svc:5000/user-uploads/"+id+":latest", reviewed, now)
if err != nil || !ok { if err != nil || !ok {
t.Fatalf("ApproveSubmission = (%v, %v), want (true, nil)", ok, err) t.Fatalf("ApproveSubmission = (%v, %v), want (true, nil)", ok, err)
} }
if ok, err := s.ApproveSubmission(ctx, id, "[email protected]", "registry/x:1", now); err != nil || ok { if ok, err := s.ApproveSubmission(ctx, id, "[email protected]", "registry/x:1", reviewed, now); err != nil || ok {
t.Fatalf("second approve = (%v, %v), want (false, nil)", ok, err) t.Fatalf("second approve = (%v, %v), want (false, nil)", ok, err)
} }
// A reviewed row's digest is frozen: a late upload cannot rewrite it.
if ok, err := s.SetContextDigest(ctx, id, swapped); err != nil || ok {
t.Fatalf("SetContextDigest after approve = (%v, %v), want (false, nil)", ok, err)
}
if ok, err := s.RejectSubmission(ctx, id, "[email protected]", "no", now); err != nil || ok { if ok, err := s.RejectSubmission(ctx, id, "[email protected]", "no", now); err != nil || ok {
t.Fatalf("reject after approve = (%v, %v), want (false, nil)", ok, err) t.Fatalf("reject after approve = (%v, %v), want (false, nil)", ok, err)
} }
@@ -1218,15 +1235,17 @@ func TestBuildStoreContract(t *testing.T) {
now := mustNow() now := mustNow()
done := "bld-done-" + suffix(t) done := "bld-done-" + suffix(t)
digest := strings.Repeat("c", 64)
if err := s.CreateBuild(ctx, &build.Build{ID: done, ImageRef: "registry.felis.svc:5000/user-uploads/" + done + ":latest", if err := s.CreateBuild(ctx, &build.Build{ID: done, ImageRef: "registry.felis.svc:5000/user-uploads/" + done + ":latest",
Status: build.StatusPending, RequestedBy: "pgint", Dockerfile: "FROM scratch\n", ContextRef: "http://api/x"}); err != nil { Status: build.StatusPending, RequestedBy: "pgint", Dockerfile: "FROM scratch\n", ContextRef: "http://api/x",
ContextDigest: digest}); err != nil {
t.Fatalf("CreateBuild: %v", err) t.Fatalf("CreateBuild: %v", err)
} }
got, err := s.GetBuild(ctx, done) got, err := s.GetBuild(ctx, done)
if err != nil { if err != nil {
t.Fatalf("GetBuild: %v", err) t.Fatalf("GetBuild: %v", err)
} }
if got.Status != build.StatusPending || got.JobName != "" { if got.Status != build.StatusPending || got.JobName != "" || got.ContextDigest != digest {
t.Fatalf("fresh build = %+v", got) t.Fatalf("fresh build = %+v", got)
} }
if err := s.SetBuildJob(ctx, done, "job-"+suffix(t)); err != nil { if err := s.SetBuildJob(ctx, done, "job-"+suffix(t)); err != nil {
@@ -0,0 +1,12 @@
-- Binds a submission's review to the exact bytes that get built. The upload
-- records the sha256 of the stored context tarball; an admin approves naming
-- the digest they reviewed, and the approve CAS only wins while the row still
-- carries it. A re-upload while pending replaces both the blob and the digest,
-- so an approval issued against the old digest loses instead of building
-- content nobody saw. NULL marks a context uploaded before digests were kept
-- (or no upload yet); such a row cannot be approved until it is uploaded again.
ALTER TABLE image_submissions ADD COLUMN context_sha256 text;
-- The digest the build was allowed to consume. The context-fetch step refuses
-- any other bytes, so a context swapped after approval fails the build.
ALTER TABLE image_builds ADD COLUMN context_digest text;
+15 -5
View File
@@ -22,7 +22,7 @@ func NewPGStore(db *sql.DB) *PGStore { return &PGStore{db: db} }
var _ Store = (*PGStore)(nil) var _ Store = (*PGStore)(nil)
const submissionColumns = `id, submitted_by, display_name, context_ref, status, const submissionColumns = `id, submitted_by, display_name, context_ref, status,
image_ref, build_id, reviewed_by, reject_reason, created_at, reviewed_at` image_ref, build_id, reviewed_by, reject_reason, created_at, reviewed_at, context_sha256`
func (s *PGStore) CreateSubmission(ctx context.Context, sub *Submission) error { func (s *PGStore) CreateSubmission(ctx context.Context, sub *Submission) error {
const q = `INSERT INTO image_submissions const q = `INSERT INTO image_submissions
@@ -77,12 +77,20 @@ func (s *PGStore) cas(ctx context.Context, q string, args ...any) (bool, error)
} }
// ApproveSubmission is the approve CAS: it flips the row only while it is still // ApproveSubmission is the approve CAS: it flips the row only while it is still
// pending_review, so a concurrent reviewer cannot also win. // pending_review AND still carries the digest the reviewer approved, so neither
func (s *PGStore) ApproveSubmission(ctx context.Context, id, reviewedBy, imageRef string, at time.Time) (bool, error) { // a concurrent reviewer nor a re-upload after the review can let it win.
func (s *PGStore) ApproveSubmission(ctx context.Context, id, reviewedBy, imageRef, digest string, at time.Time) (bool, error) {
const q = `UPDATE image_submissions const q = `UPDATE image_submissions
SET status = 'approved', image_ref = $2, reviewed_by = $3, reviewed_at = $4 SET status = 'approved', image_ref = $2, reviewed_by = $3, reviewed_at = $4
WHERE id = $1 AND status = 'pending_review' AND COALESCE(context_sha256, '') = $5`
return s.cas(ctx, q, id, imageRef, reviewedBy, at, digest)
}
// SetContextDigest records an upload's digest, only while the row is pending.
func (s *PGStore) SetContextDigest(ctx context.Context, id, digest string) (bool, error) {
const q = `UPDATE image_submissions SET context_sha256 = $2
WHERE id = $1 AND status = 'pending_review'` WHERE id = $1 AND status = 'pending_review'`
return s.cas(ctx, q, id, imageRef, reviewedBy, at) return s.cas(ctx, q, id, digest)
} }
// RejectSubmission is the reject CAS, mirroring ApproveSubmission. // RejectSubmission is the reject CAS, mirroring ApproveSubmission.
@@ -164,10 +172,11 @@ func scanSubmissionRows(row rowScanner) (*Submission, error) {
status string status string
imageRef, buildID, reviewedBy, rejectReason sql.NullString imageRef, buildID, reviewedBy, rejectReason sql.NullString
reviewedAt sql.NullTime reviewedAt sql.NullTime
digest sql.NullString
) )
if err := row.Scan( if err := row.Scan(
&sub.ID, &sub.SubmittedBy, &sub.DisplayName, &sub.ContextRef, &status, &sub.ID, &sub.SubmittedBy, &sub.DisplayName, &sub.ContextRef, &status,
&imageRef, &buildID, &reviewedBy, &rejectReason, &sub.CreatedAt, &reviewedAt, &imageRef, &buildID, &reviewedBy, &rejectReason, &sub.CreatedAt, &reviewedAt, &digest,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -176,6 +185,7 @@ func scanSubmissionRows(row rowScanner) (*Submission, error) {
sub.BuildID = buildID.String sub.BuildID = buildID.String
sub.ReviewedBy = reviewedBy.String sub.ReviewedBy = reviewedBy.String
sub.RejectReason = rejectReason.String sub.RejectReason = rejectReason.String
sub.ContextSHA256 = digest.String
if reviewedAt.Valid { if reviewedAt.Valid {
t := reviewedAt.Time t := reviewedAt.Time
sub.ReviewedAt = &t sub.ReviewedAt = &t
+78 -13
View File
@@ -59,6 +59,7 @@ import (
"bufio" "bufio"
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
@@ -98,6 +99,11 @@ var (
// an honest 503, never a 500, exactly as the restore executor does when its // an honest 503, never a 500, exactly as the restore executor does when its
// integration is not wired. // integration is not wired.
ErrUploadsUnavailable = errors.New("submit: context upload transport not configured") ErrUploadsUnavailable = errors.New("submit: context upload transport not configured")
// ErrContextChanged reports that the submission's context digest no longer
// matches the one the reviewer approved: the submitter uploaded again after
// the review began. The API maps it to 409 so the panel reloads and the admin
// reviews the new bytes.
ErrContextChanged = errors.New("submit: the build context changed since it was reviewed")
// ErrBlobNotFound reports that a submission has no stored context blob (or it // ErrBlobNotFound reports that a submission has no stored context blob (or it
// was never uploaded). The internal context-fetch route maps it to 404, the // was never uploaded). The internal context-fetch route maps it to 404, the
// same distinction Exists draws for Approve. // same distinction Exists draws for Approve.
@@ -170,6 +176,11 @@ type Submission struct {
RejectReason string `json:"reject_reason,omitempty"` RejectReason string `json:"reject_reason,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty"` ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
// ContextSHA256 is the lowercase hex sha256 of the stored context tarball,
// recorded by the upload that wrote it; empty until one is uploaded. Approve
// must name it, and the build refuses any other bytes.
ContextSHA256 string `json:"context_sha256,omitempty"`
} }
// Store is the business-layer persistence the Manager depends on. It is an // Store is the business-layer persistence the Manager depends on. It is an
@@ -192,7 +203,14 @@ type Store interface {
// derived image_ref, the reviewer and reviewed_at. It reports whether THIS // derived image_ref, the reviewer and reviewed_at. It reports whether THIS
// call won the transition: false means a concurrent review already moved the // call won the transition: false means a concurrent review already moved the
// row, so the caller must NOT start a build. // row, so the caller must NOT start a build.
ApproveSubmission(ctx context.Context, id, reviewedBy, imageRef string, at time.Time) (won bool, err error) //
// digest is part of the predicate: the row's context_sha256 (NULL read as "")
// must equal it, so an approval of content that a re-upload has since
// replaced loses the CAS.
ApproveSubmission(ctx context.Context, id, reviewedBy, imageRef, digest string, at time.Time) (won bool, err error)
// SetContextDigest records the sha256 of the context an upload just stored,
// only while the row is still pending_review. Reports whether it did.
SetContextDigest(ctx context.Context, id, digest string) (bool, error)
// RejectSubmission atomically flips pending_review -> rejected, recording the // RejectSubmission atomically flips pending_review -> rejected, recording the
// reviewer, the reason and reviewed_at. Reports whether THIS call won. // reviewer, the reason and reviewed_at. Reports whether THIS call won.
RejectSubmission(ctx context.Context, id, reviewedBy, reason string, at time.Time) (won bool, err error) RejectSubmission(ctx context.Context, id, reviewedBy, reason string, at time.Time) (won bool, err error)
@@ -371,14 +389,26 @@ func (m *Manager) deriveContextRef(id string) string {
} }
// OpenContext returns the stored build context for id — the read path behind the // OpenContext returns the stored build context for id — the read path behind the
// internal context-fetch route. It requires the upload transport (Blobs): with no // internal context-fetch route and the reviewer's download — together with the
// transport there is no blob to read, so it reports ErrUploadsUnavailable, the // sha256 the row records for it (empty for a context uploaded before digests
// same honest 503 the upload endpoint gives. // were kept). The row is read first: a re-upload landing between the two reads
func (m *Manager) OpenContext(ctx context.Context, id string) (io.ReadCloser, error) { // shows up as bytes that do not match the digest, which the caller's verifying
// stream refuses. It requires the upload transport (Blobs): with no transport
// there is no blob to read, so it reports ErrUploadsUnavailable, the same honest
// 503 the upload endpoint gives.
func (m *Manager) OpenContext(ctx context.Context, id string) (io.ReadCloser, string, error) {
if m.Blobs == nil { if m.Blobs == nil {
return nil, ErrUploadsUnavailable return nil, "", ErrUploadsUnavailable
} }
return m.Blobs.Open(ctx, id) sub, err := m.Store.GetSubmission(ctx, id)
if err != nil {
return nil, "", err
}
rc, err := m.Blobs.Open(ctx, id)
if err != nil {
return nil, "", err
}
return rc, sub.ContextSHA256, nil
} }
// auditDockerfile is the audit-archive Dockerfile recorded on the build row. It // auditDockerfile is the audit-archive Dockerfile recorded on the build row. It
@@ -466,7 +496,10 @@ func (m *Manager) Create(ctx context.Context, req CreateRequest) (*Submission, e
// refused as a spent allowance (403) before the excess is persisted. // refused as a spent allowance (403) before the excess is persisted.
// //
// A re-upload while still pending atomically supersedes the previous blob, so a // A re-upload while still pending atomically supersedes the previous blob, so a
// user can fix their pack before an admin reviews it. // user can fix their pack before an admin reviews it. The upload hashes what it
// stores and records the digest on the row; Approve binds to that digest, so a
// re-upload after the admin looked makes the approval fail rather than build
// the new bytes unseen.
func (m *Manager) UploadContext(ctx context.Context, id, submittedBy string, r io.Reader) (*Submission, error) { func (m *Manager) UploadContext(ctx context.Context, id, submittedBy string, r io.Reader) (*Submission, error) {
if strings.TrimSpace(submittedBy) == "" { if strings.TrimSpace(submittedBy) == "" {
return nil, invalidf("submitter identity is required") return nil, invalidf("submitter identity is required")
@@ -518,9 +551,21 @@ func (m *Manager) UploadContext(ctx context.Context, id, submittedBy string, r i
// refused as a spent allowance, never as a malformed request. // refused as a spent allowance, never as a malformed request.
limit, over = remaining, errStorageQuota limit, over = remaining, errStorageQuota
} }
if _, err := m.Blobs.Put(ctx, id, &cappedReader{r: br, left: limit, over: over}); err != nil { h := sha256.New()
if _, err := m.Blobs.Put(ctx, id, io.TeeReader(&cappedReader{r: br, left: limit, over: over}, h)); err != nil {
return nil, err return nil, err
} }
digest := hex.EncodeToString(h.Sum(nil))
won, err := m.Store.SetContextDigest(ctx, id, digest)
if err != nil {
return nil, err
}
if !won {
// Reviewed while the bytes streamed in. The blob was replaced anyway, but
// the approved digest no longer matches it, so its build refuses them.
return nil, ErrAlreadyReviewed
}
sub.ContextSHA256 = digest
return sub, nil return sub, nil
} }
@@ -611,10 +656,14 @@ func (c *cappedReader) Read(p []byte) (int, error) {
// one and the reason the error is differentiated. The alternative ordering // one and the reason the error is differentiated. The alternative ordering
// (Submit-then-CAS) would either double-build under a concurrent approve or // (Submit-then-CAS) would either double-build under a concurrent approve or
// orphan a build on a lost race, both worse still. // orphan a build on a lost race, both worse still.
func (m *Manager) Approve(ctx context.Context, id, reviewedBy string) (*Submission, error) { func (m *Manager) Approve(ctx context.Context, id, reviewedBy, expectedDigest string) (*Submission, error) {
if strings.TrimSpace(reviewedBy) == "" { if strings.TrimSpace(reviewedBy) == "" {
return nil, invalidf("reviewer identity is required") return nil, invalidf("reviewer identity is required")
} }
expectedDigest = strings.ToLower(strings.TrimSpace(expectedDigest))
if m.Blobs != nil && !build.IsSHA256Hex(expectedDigest) {
return nil, invalidf("expected_digest must be the sha256 of the context you reviewed (64 hex characters)")
}
sub, err := m.Store.GetSubmission(ctx, id) sub, err := m.Store.GetSubmission(ctx, id)
if err != nil { if err != nil {
@@ -638,6 +687,12 @@ func (m *Manager) Approve(ctx context.Context, id, reviewedBy string) (*Submissi
if !ok { if !ok {
return nil, invalidf("no build context has been uploaded for this submission") return nil, invalidf("no build context has been uploaded for this submission")
} }
if sub.ContextSHA256 == "" {
return nil, invalidf("this context was uploaded before digests were recorded; the submitter must upload it again")
}
if sub.ContextSHA256 != expectedDigest {
return nil, ErrContextChanged
}
} }
imageRef := m.deriveImageRef(id) imageRef := m.deriveImageRef(id)
@@ -648,19 +703,29 @@ func (m *Manager) Approve(ctx context.Context, id, reviewedBy string) (*Submissi
BaseImage: "", // declared inside the uploaded context, unknown here BaseImage: "", // declared inside the uploaded context, unknown here
RequestedBy: reviewedBy, RequestedBy: reviewedBy,
} }
// Pin the build to the reviewed bytes: the fetch step refuses anything else,
// which covers an upload that was already streaming when the CAS won. Only an
// http(s) context has that step; the installed API always derives one.
if build.IsHTTPContextRef(sub.ContextRef) {
req.ContextDigest = sub.ContextSHA256
}
// Pre-validate against the SAME registry the Builder enforces, BEFORE the CAS, // Pre-validate against the SAME registry the Builder enforces, BEFORE the CAS,
// so a deterministic config error cannot strand the row in approved. // so a deterministic config error cannot strand the row in approved.
if err := build.Validate(req, build.Config{RegistryURL: m.Registry}); err != nil { if err := build.Validate(req, build.Config{RegistryURL: m.Registry, ContextOrigin: m.ContextBaseURL}); err != nil {
return nil, err return nil, err
} }
now := m.now() now := m.now()
won, err := m.Store.ApproveSubmission(ctx, id, reviewedBy, imageRef, now) won, err := m.Store.ApproveSubmission(ctx, id, reviewedBy, imageRef, sub.ContextSHA256, now)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if !won { if !won {
// Lost the race to a concurrent approve/reject. // Lost the race: a concurrent approve/reject moved the row, or a re-upload
// replaced the digest between the read above and the CAS.
if cur, err := m.Store.GetSubmission(ctx, id); err == nil && cur.Status == StatusPendingReview {
return nil, ErrContextChanged
}
return nil, ErrAlreadyReviewed return nil, ErrAlreadyReviewed
} }
+133 -16
View File
@@ -3,6 +3,8 @@ package submit
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -103,6 +105,10 @@ type fakeStore struct {
deleteErr error deleteErr error
linked []string // "id=buildID" recorder linked []string // "id=buildID" recorder
// beforeApprove runs between the Manager's read and its CAS, the window a
// concurrent re-upload lands in.
beforeApprove func()
} }
func newFakeStore() *fakeStore { return &fakeStore{subs: map[string]*Submission{}} } func newFakeStore() *fakeStore { return &fakeStore{subs: map[string]*Submission{}} }
@@ -156,12 +162,15 @@ func (f *fakeStore) ListSubmissionsBy(_ context.Context, by string) ([]Submissio
return out, nil return out, nil
} }
func (f *fakeStore) ApproveSubmission(_ context.Context, id, reviewedBy, imageRef string, at time.Time) (bool, error) { func (f *fakeStore) ApproveSubmission(_ context.Context, id, reviewedBy, imageRef, digest string, at time.Time) (bool, error) {
if f.beforeApprove != nil {
f.beforeApprove()
}
if f.approveErr != nil { if f.approveErr != nil {
return false, f.approveErr return false, f.approveErr
} }
s, ok := f.subs[id] s, ok := f.subs[id]
if !ok || s.Status != StatusPendingReview { if !ok || s.Status != StatusPendingReview || s.ContextSHA256 != digest {
return false, nil // CAS lost / nonexistent return false, nil // CAS lost / nonexistent
} }
s.Status = StatusApproved s.Status = StatusApproved
@@ -172,6 +181,15 @@ func (f *fakeStore) ApproveSubmission(_ context.Context, id, reviewedBy, imageRe
return true, nil return true, nil
} }
func (f *fakeStore) SetContextDigest(_ context.Context, id, digest string) (bool, error) {
s, ok := f.subs[id]
if !ok || s.Status != StatusPendingReview {
return false, nil
}
s.ContextSHA256 = digest
return true, nil
}
func (f *fakeStore) RejectSubmission(_ context.Context, id, reviewedBy, reason string, at time.Time) (bool, error) { func (f *fakeStore) RejectSubmission(_ context.Context, id, reviewedBy, reason string, at time.Time) (bool, error) {
if f.rejectErr != nil { if f.rejectErr != nil {
return false, f.rejectErr return false, f.rejectErr
@@ -307,14 +325,14 @@ func TestContextRefIsFetchURLAndOpenContextServesIt(t *testing.T) {
} }
// Before any upload the read path reports not-found (the route's 404). // Before any upload the read path reports not-found (the route's 404).
if _, err := m.OpenContext(ctx, sub.ID); !errors.Is(err, ErrBlobNotFound) { if _, _, err := m.OpenContext(ctx, sub.ID); !errors.Is(err, ErrBlobNotFound) {
t.Fatalf("OpenContext before upload = %v, want ErrBlobNotFound", err) t.Fatalf("OpenContext before upload = %v, want ErrBlobNotFound", err)
} }
payload := "\x1f\x8b\x08\x00payload" payload := "\x1f\x8b\x08\x00payload"
if _, err := m.UploadContext(ctx, sub.ID, "user-1", strings.NewReader(payload)); err != nil { if _, err := m.UploadContext(ctx, sub.ID, "user-1", strings.NewReader(payload)); err != nil {
t.Fatalf("UploadContext: %v", err) t.Fatalf("UploadContext: %v", err)
} }
rc, err := m.OpenContext(ctx, sub.ID) rc, digest, err := m.OpenContext(ctx, sub.ID)
if err != nil { if err != nil {
t.Fatalf("OpenContext: %v", err) t.Fatalf("OpenContext: %v", err)
} }
@@ -323,13 +341,16 @@ func TestContextRefIsFetchURLAndOpenContextServesIt(t *testing.T) {
if string(got) != payload { if string(got) != payload {
t.Fatalf("OpenContext served %q, want %q", got, payload) t.Fatalf("OpenContext served %q, want %q", got, payload)
} }
if digest != sha256Hex(payload) {
t.Fatalf("OpenContext digest = %q, want the payload's sha256", digest)
}
} }
// No upload transport ⇒ no readable blob: the route reports the same 503 the // No upload transport ⇒ no readable blob: the route reports the same 503 the
// upload endpoint does, rather than a misleading 404. // upload endpoint does, rather than a misleading 404.
func TestOpenContextWithoutTransportIsUnavailable(t *testing.T) { func TestOpenContextWithoutTransportIsUnavailable(t *testing.T) {
m, _, _ := newManager() m, _, _ := newManager()
if _, err := m.OpenContext(context.Background(), "sub-1"); !errors.Is(err, ErrUploadsUnavailable) { if _, _, err := m.OpenContext(context.Background(), "sub-1"); !errors.Is(err, ErrUploadsUnavailable) {
t.Fatalf("OpenContext with nil Blobs = %v, want ErrUploadsUnavailable", err) t.Fatalf("OpenContext with nil Blobs = %v, want ErrUploadsUnavailable", err)
} }
} }
@@ -362,7 +383,7 @@ func TestApproveStartsExactlyOneBuildAndLinks(t *testing.T) {
m, st, bl := newManager() m, st, bl := newManager()
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"}) seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
sub, err := m.Approve(context.Background(), seed.ID, "[email protected]") sub, err := m.Approve(context.Background(), seed.ID, "[email protected]", "")
if err != nil { if err != nil {
t.Fatalf("Approve: %v", err) t.Fatalf("Approve: %v", err)
} }
@@ -428,11 +449,11 @@ func TestApproveIsCASNoDoubleBuild(t *testing.T) {
m, _, bl := newManager() m, _, bl := newManager()
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"}) seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
if _, err := m.Approve(context.Background(), seed.ID, "admin@x"); err != nil { if _, err := m.Approve(context.Background(), seed.ID, "admin@x", ""); err != nil {
t.Fatalf("first Approve: %v", err) t.Fatalf("first Approve: %v", err)
} }
// A second approve loses the CAS and must NOT start another build. // A second approve loses the CAS and must NOT start another build.
_, err := m.Approve(context.Background(), seed.ID, "admin@x") _, err := m.Approve(context.Background(), seed.ID, "admin@x", "")
if !errors.Is(err, ErrAlreadyReviewed) { if !errors.Is(err, ErrAlreadyReviewed) {
t.Fatalf("second Approve err = %v, want ErrAlreadyReviewed", err) t.Fatalf("second Approve err = %v, want ErrAlreadyReviewed", err)
} }
@@ -447,7 +468,7 @@ func TestApproveRejectedSubmission(t *testing.T) {
if _, err := m.Reject(context.Background(), seed.ID, "admin@x", "nope"); err != nil { if _, err := m.Reject(context.Background(), seed.ID, "admin@x", "nope"); err != nil {
t.Fatalf("Reject: %v", err) t.Fatalf("Reject: %v", err)
} }
_, err := m.Approve(context.Background(), seed.ID, "admin@x") _, err := m.Approve(context.Background(), seed.ID, "admin@x", "")
if !errors.Is(err, ErrAlreadyReviewed) { if !errors.Is(err, ErrAlreadyReviewed) {
t.Fatalf("Approve after reject err = %v, want ErrAlreadyReviewed", err) t.Fatalf("Approve after reject err = %v, want ErrAlreadyReviewed", err)
} }
@@ -458,7 +479,7 @@ func TestApproveRejectedSubmission(t *testing.T) {
func TestApproveUnknown(t *testing.T) { func TestApproveUnknown(t *testing.T) {
m, _, _ := newManager() m, _, _ := newManager()
_, err := m.Approve(context.Background(), "sub-nope", "admin@x") _, err := m.Approve(context.Background(), "sub-nope", "admin@x", "")
if !errors.Is(err, ErrNotFound) { if !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err) t.Fatalf("err = %v, want ErrNotFound", err)
} }
@@ -472,7 +493,7 @@ func TestApproveBuildFailureLeavesApprovedUnlinked(t *testing.T) {
bl.submitErr = errors.New("apiserver unreachable") bl.submitErr = errors.New("apiserver unreachable")
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"}) seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
_, err := m.Approve(context.Background(), seed.ID, "admin@x") _, err := m.Approve(context.Background(), seed.ID, "admin@x", "")
if err == nil { if err == nil {
t.Fatal("Approve should surface the build hand-off failure") t.Fatal("Approve should surface the build hand-off failure")
} }
@@ -503,7 +524,7 @@ func TestApproveLinkFailureNamesRunningBuild(t *testing.T) {
st.linkErr = errors.New("db write timeout") st.linkErr = errors.New("db write timeout")
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"}) seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
_, err := m.Approve(context.Background(), seed.ID, "admin@x") _, err := m.Approve(context.Background(), seed.ID, "admin@x", "")
if err == nil { if err == nil {
t.Fatal("Approve must surface the link-write failure (a build is running)") t.Fatal("Approve must surface the link-write failure (a build is running)")
} }
@@ -544,7 +565,7 @@ func TestApproveValidatesBeforeCAS(t *testing.T) {
m.Registry = "" // derived ref becomes "/user-uploads/...", not host-qualified m.Registry = "" // derived ref becomes "/user-uploads/...", not host-qualified
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"}) seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
_, err := m.Approve(context.Background(), seed.ID, "admin@x") _, err := m.Approve(context.Background(), seed.ID, "admin@x", "")
if !errors.Is(err, build.ErrInvalid) { if !errors.Is(err, build.ErrInvalid) {
t.Fatalf("err = %v, want build.ErrInvalid (pre-CAS validation)", err) t.Fatalf("err = %v, want build.ErrInvalid (pre-CAS validation)", err)
} }
@@ -1009,7 +1030,7 @@ func TestApproveRefusesMissingContext(t *testing.T) {
m.Blobs = newFakeBlobs() // empty → Exists=false m.Blobs = newFakeBlobs() // empty → Exists=false
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"}) seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
_, err := m.Approve(context.Background(), seed.ID, "admin@x") _, err := m.Approve(context.Background(), seed.ID, "admin@x", sha256Hex("never uploaded"))
if !errors.Is(err, ErrInvalid) { if !errors.Is(err, ErrInvalid) {
t.Fatalf("err = %v, want ErrInvalid (no context uploaded)", err) t.Fatalf("err = %v, want ErrInvalid (no context uploaded)", err)
} }
@@ -1026,12 +1047,17 @@ func TestApproveProceedsWithUploadedContext(t *testing.T) {
// build through the SAME gated Builder. // build through the SAME gated Builder.
m, _, bl := newManager() m, _, bl := newManager()
m.Blobs = newFakeBlobs() m.Blobs = newFakeBlobs()
m.ContextBaseURL = "http://felis-api-internal.felis.svc.cluster.local:8081"
seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"}) seed, _ := m.Create(context.Background(), CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
if _, err := m.UploadContext(context.Background(), seed.ID, "user-1", strings.NewReader(gzBody("mods"))); err != nil { up, err := m.UploadContext(context.Background(), seed.ID, "user-1", strings.NewReader(gzBody("mods")))
if err != nil {
t.Fatalf("UploadContext: %v", err) t.Fatalf("UploadContext: %v", err)
} }
if up.ContextSHA256 != sha256Hex(gzBody("mods")) {
t.Fatalf("upload digest = %q, want the stored bytes' sha256", up.ContextSHA256)
}
sub, err := m.Approve(context.Background(), seed.ID, "admin@x") sub, err := m.Approve(context.Background(), seed.ID, "admin@x", up.ContextSHA256)
if err != nil { if err != nil {
t.Fatalf("Approve: %v", err) t.Fatalf("Approve: %v", err)
} }
@@ -1041,6 +1067,97 @@ func TestApproveProceedsWithUploadedContext(t *testing.T) {
if bl.calls != 1 { if bl.calls != 1 {
t.Fatalf("builds started = %d, want 1", bl.calls) t.Fatalf("builds started = %d, want 1", bl.calls)
} }
if bl.got[0].ContextDigest != up.ContextSHA256 {
t.Fatalf("build digest = %q, want the approved %q", bl.got[0].ContextDigest, up.ContextSHA256)
}
}
// Review binds to bytes (build-supply-chain-6): an approval names the digest
// the admin inspected, and a re-upload after that makes it fail instead of
// building content nobody saw.
func TestApproveBindsTheReviewedDigest(t *testing.T) {
ctx := context.Background()
m, st, bl := newManager()
m.Blobs = newFakeBlobs()
seed, _ := m.Create(ctx, CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
benign, evil := gzBody("benign"), gzBody("evil")
if _, err := m.UploadContext(ctx, seed.ID, "user-1", strings.NewReader(benign)); err != nil {
t.Fatal(err)
}
reviewed := sha256Hex(benign)
// The swap lands after the review.
if _, err := m.UploadContext(ctx, seed.ID, "user-1", strings.NewReader(evil)); err != nil {
t.Fatal(err)
}
if _, err := m.Approve(ctx, seed.ID, "admin@x", reviewed); !errors.Is(err, ErrContextChanged) {
t.Fatalf("approve after a swap = %v, want ErrContextChanged", err)
}
// The swap lands between the Manager's read and its CAS.
if _, err := m.UploadContext(ctx, seed.ID, "user-1", strings.NewReader(benign)); err != nil {
t.Fatal(err)
}
st.beforeApprove = func() { st.subs[seed.ID].ContextSHA256 = sha256Hex(evil) }
if _, err := m.Approve(ctx, seed.ID, "admin@x", reviewed); !errors.Is(err, ErrContextChanged) {
t.Fatalf("approve racing a swap = %v, want ErrContextChanged", err)
}
st.beforeApprove = nil
if st.subs[seed.ID].Status != StatusPendingReview || bl.calls != 0 {
t.Fatalf("status %q, builds %d; want still pending with nothing built", st.subs[seed.ID].Status, bl.calls)
}
for _, bad := range []string{"", "not-a-digest"} {
if _, err := m.Approve(ctx, seed.ID, "admin@x", bad); !errors.Is(err, ErrInvalid) {
t.Fatalf("approve with digest %q = %v, want ErrInvalid", bad, err)
}
}
// A context uploaded before digests were recorded must be uploaded again.
st.subs[seed.ID].ContextSHA256 = ""
if _, err := m.Approve(ctx, seed.ID, "admin@x", reviewed); !errors.Is(err, ErrInvalid) ||
!strings.Contains(err.Error(), "upload it again") {
t.Fatalf("approve of an undigested context = %v, want the re-upload instruction", err)
}
}
// An upload that finishes after the approval won cannot slip its digest in: the
// row keeps the approved one, which the build's fetch step then enforces, and
// the uploader hears that the submission was reviewed.
func TestUploadAfterApprovalKeepsTheApprovedDigest(t *testing.T) {
ctx := context.Background()
m, st, _ := newManager()
fb := newFakeBlobs()
m.Blobs = fb
seed, _ := m.Create(ctx, CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
up, _ := m.UploadContext(ctx, seed.ID, "user-1", strings.NewReader(gzBody("benign")))
// The second upload passed its pending check; the approval wins while its
// bytes are still streaming in.
m.Blobs = approvingBlobs{fakeBlobs: fb, approve: func() { st.subs[seed.ID].Status = StatusApproved }}
if _, err := m.UploadContext(ctx, seed.ID, "user-1", strings.NewReader(gzBody("evil"))); !errors.Is(err, ErrAlreadyReviewed) {
t.Fatalf("late upload = %v, want ErrAlreadyReviewed", err)
}
if st.subs[seed.ID].ContextSHA256 != up.ContextSHA256 {
t.Fatalf("row digest = %q, want the approved %q", st.subs[seed.ID].ContextSHA256, up.ContextSHA256)
}
}
// approvingBlobs flips the row to approved once Put has stored the bytes.
type approvingBlobs struct {
*fakeBlobs
approve func()
}
func (a approvingBlobs) Put(ctx context.Context, id string, r io.Reader) (int64, error) {
n, err := a.fakeBlobs.Put(ctx, id, r)
a.approve()
return n, err
}
func sha256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
} }
// keysOf lists a map's keys for test diagnostics. // keysOf lists a map's keys for test diagnostics.
+67 -2
View File
@@ -1,4 +1,6 @@
import { createHash } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { gzipSync } from "node:zlib";
import type { Plugin } from "vite"; import type { Plugin } from "vite";
import type { import type {
AutostartPolicy, AutostartPolicy,
@@ -167,6 +169,23 @@ const LOGIN_HINT_SCRIPT = `
// archiving owner; GET /backups is scoped by it for non-admins (BackupsForUser). // archiving owner; GET /backups is scoped by it for non-admins (BackupsForUser).
const GiB = 1024 ** 3; const GiB = 1024 ** 3;
const DAY_MS = 86_400_000; const DAY_MS = 86_400_000;
// Uploaded build contexts by submission id. Seeded submissions get a small
// stand-in archive so the review page's download and digest check have bytes.
const contextBlobs = new Map<string, Buffer>();
function contextBlob(id: string): Buffer {
let blob = contextBlobs.get(id);
if (!blob) {
blob = gzipSync(Buffer.from(`FROM eclipse-temurin:21-jre\n# mock build context for ${id}\n`));
contextBlobs.set(id, blob);
}
return blob;
}
function sha256Hex(b: Buffer): string {
return createHash("sha256").update(b).digest("hex");
}
const RETENTION_DAYS = 90; const RETENTION_DAYS = 90;
function backup(server: string, daysAgo: number, sizeBytes: number, formerOwner: string): BackupView { function backup(server: string, daysAgo: number, sizeBytes: number, formerOwner: string): BackupView {
@@ -329,6 +348,7 @@ function initialState(): MockState {
submitted_by: "[email protected]", submitted_by: "[email protected]",
display_name: "RLCraft Survival Pack", display_name: "RLCraft Survival Pack",
context_ref: "minio/contexts/sub-owner-1/context.tar.gz", context_ref: "minio/contexts/sub-owner-1/context.tar.gz",
context_sha256: sha256Hex(contextBlob("sub-owner-1")),
status: "pending_review", status: "pending_review",
created_at: new Date(Date.now() - 1800000).toISOString(), created_at: new Date(Date.now() - 1800000).toISOString(),
}, },
@@ -337,6 +357,7 @@ function initialState(): MockState {
submitted_by: "[email protected]", submitted_by: "[email protected]",
display_name: "ATM 9 Server Pack", display_name: "ATM 9 Server Pack",
context_ref: "minio/contexts/sub-owner-2/context.tar.gz", context_ref: "minio/contexts/sub-owner-2/context.tar.gz",
context_sha256: sha256Hex(contextBlob("sub-owner-2")),
status: "approved", status: "approved",
image_ref: "registry.felis.svc:5000/user-uploads/sub-owner-2:latest", image_ref: "registry.felis.svc:5000/user-uploads/sub-owner-2:latest",
build_id: "bld-3", build_id: "bld-3",
@@ -349,6 +370,7 @@ function initialState(): MockState {
submitted_by: "[email protected]", submitted_by: "[email protected]",
display_name: "Oversized Custom Modpack", display_name: "Oversized Custom Modpack",
context_ref: "minio/contexts/sub-owner-3/context.tar.gz", context_ref: "minio/contexts/sub-owner-3/context.tar.gz",
context_sha256: sha256Hex(contextBlob("sub-owner-3")),
status: "rejected", status: "rejected",
reviewed_by: "[email protected]", reviewed_by: "[email protected]",
created_at: new Date(Date.now() - 172800000).toISOString(), created_at: new Date(Date.now() - 172800000).toISOString(),
@@ -360,6 +382,7 @@ function initialState(): MockState {
submitted_by: "[email protected]", submitted_by: "[email protected]",
display_name: "Create: Astral pack", display_name: "Create: Astral pack",
context_ref: "minio/contexts/sub-2/context.tar.gz", context_ref: "minio/contexts/sub-2/context.tar.gz",
context_sha256: sha256Hex(contextBlob("sub-2")),
status: "approved", status: "approved",
image_ref: "registry.felis.svc:5000/user-uploads/sub-2:latest", image_ref: "registry.felis.svc:5000/user-uploads/sub-2:latest",
build_id: "bld-1", build_id: "bld-1",
@@ -372,6 +395,7 @@ function initialState(): MockState {
submitted_by: "[email protected]", submitted_by: "[email protected]",
display_name: "Dangerous Modpack (Exploitative)", display_name: "Dangerous Modpack (Exploitative)",
context_ref: "minio/contexts/sub-3/context.tar.gz", context_ref: "minio/contexts/sub-3/context.tar.gz",
context_sha256: sha256Hex(contextBlob("sub-3")),
status: "rejected", status: "rejected",
reviewed_by: "[email protected]", reviewed_by: "[email protected]",
reject_reason: "Contains malicious code in scripts/run.sh that tries to download remote malware.", reject_reason: "Contains malicious code in scripts/run.sh that tries to download remote malware.",
@@ -1404,13 +1428,41 @@ async function handleSubmissionRoute(ctx: SessionContext): Promise<boolean> {
return true; return true;
} }
// Read the body stream to end so the socket is clean const chunks: Buffer[] = [];
for await (const _ of ctx.req) { /* discard */ } for await (const chunk of ctx.req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const blob = Buffer.concat(chunks);
contextBlobs.set(id, blob);
sub.context_sha256 = sha256Hex(blob);
sendJSON(ctx.res, 200, sub); sendJSON(ctx.res, 200, sub);
return true; return true;
} }
// GET /api/v1/submissions/{id}/context — the reviewer's download, carrying the
// digest the approval must name.
if (isAdminSubmissions && is("GET", ctx) && ctx.parts[4] === "context" && ctx.parts.length === 5) {
if (!isAdmin(ctx.account.role)) {
sendError(ctx.res, 403, "forbidden", "admin account required");
return true;
}
const id = ctx.parts[3];
const sub = ctx.state.submissions.find((s) => s.id === id);
if (!sub || !sub.context_sha256) {
sendError(ctx.res, 404, "not_found", "no context uploaded for this submission");
return true;
}
const blob = contextBlob(id);
ctx.res.writeHead(200, {
"Content-Type": "application/gzip",
"Content-Disposition": `attachment; filename="${id}-context.tar.gz"`,
"X-Felis-Context-Sha256": sha256Hex(blob),
});
ctx.res.end(blob);
return true;
}
// GET /api/v1/submissions // GET /api/v1/submissions
if (isAdminSubmissions && is("GET", ctx) && ctx.parts.length === 3) { if (isAdminSubmissions && is("GET", ctx) && ctx.parts.length === 3) {
if (!isAdmin(ctx.account.role)) { if (!isAdmin(ctx.account.role)) {
@@ -1437,6 +1489,19 @@ async function handleSubmissionRoute(ctx: SessionContext): Promise<boolean> {
sendError(ctx.res, 409, "already_reviewed", "submission already reviewed"); sendError(ctx.res, 409, "already_reviewed", "submission already reviewed");
return true; return true;
} }
const { expected_digest: expected } = await readJSON<{ expected_digest?: string }>(ctx.req);
if (!/^[0-9a-f]{64}$/.test(expected?.trim().toLowerCase() ?? "")) {
sendError(ctx.res, 400, "bad_request", "expected_digest must be the sha256 of the context you reviewed (64 hex characters)");
return true;
}
if (!sub.context_sha256) {
sendError(ctx.res, 400, "bad_request", "this context was uploaded before digests were recorded; the submitter must upload it again");
return true;
}
if (expected!.trim().toLowerCase() !== sub.context_sha256) {
sendError(ctx.res, 409, "context_changed", "the build context was uploaded again after it was reviewed; review the new upload before approving");
return true;
}
sub.status = "approved"; sub.status = "approved";
sub.reviewed_by = ctx.account.email; sub.reviewed_by = ctx.account.email;
@@ -23,6 +23,12 @@
"context_ref_placeholder": "e.g. minio/contexts/my-modpack.tar.gz", "context_ref_placeholder": "e.g. minio/contexts/my-modpack.tar.gz",
"download_context_btn": "Download context", "download_context_btn": "Download context",
"download_context_hint": "Downloads the uploaded context (.tar.gz) — it contains the Dockerfile that will actually be executed.", "download_context_hint": "Downloads the uploaded context (.tar.gz) — it contains the Dockerfile that will actually be executed.",
"context_sha256_label": "Context SHA-256",
"context_sha256_hint": "Approving binds this digest: if the submitter uploads again, the build refuses to run. When reviewing offline, compare it with sha256sum of your download.",
"context_sha256_missing": "No build context uploaded yet, or it was uploaded before digests were recorded and must be uploaded again.",
"context_sha256_downloaded": "Matches the file you just downloaded; approving binds exactly these bytes.",
"context_sha256_stale": "The copy you downloaded ({{digest}}…) was replaced by a newer upload; download and review it again.",
"approve_needs_context": "Nothing to approve: the context has not been uploaded, or must be uploaded again",
"base_image_label": "Base Image", "base_image_label": "Base Image",
"base_image_placeholder": "e.g. library/postgres:15", "base_image_placeholder": "e.g. library/postgres:15",
"view_logs_btn": "Logs", "view_logs_btn": "Logs",
@@ -52,6 +52,7 @@
"build_unavailable": "Image builds aren't available right now.", "build_unavailable": "Image builds aren't available right now.",
"build_logs_unavailable": "Build logs aren't available right now.", "build_logs_unavailable": "Build logs aren't available right now.",
"already_reviewed": "This submission has already been reviewed.", "already_reviewed": "This submission has already been reviewed.",
"context_changed": "The submitter uploaded the build context again after you reviewed it. Download and review the new upload before approving.",
"submission_quota_exceeded": "Your submission quota is full: too many pending reviews, or your stored uploads are at the limit.", "submission_quota_exceeded": "Your submission quota is full: too many pending reviews, or your stored uploads are at the limit.",
"submission_cooldown": "Too many submission requests — try again shortly.", "submission_cooldown": "Too many submission requests — try again shortly.",
"submissions_unavailable": "Submissions aren't available right now.", "submissions_unavailable": "Submissions aren't available right now.",
@@ -35,6 +35,8 @@
"error_file_required": "Build context file is required.", "error_file_required": "Build context file is required.",
"field_context_ref": "Context Reference", "field_context_ref": "Context Reference",
"field_image_ref": "Image Reference", "field_image_ref": "Image Reference",
"field_context_sha256": "Context SHA-256",
"field_context_sha256_hint": "The digest of the file the platform received; compare it with sha256sum of your local file. Approval binds exactly this content.",
"clear_btn": "Clear", "clear_btn": "Clear",
"file_hint": "Supports .tar.gz (max 1GB)", "file_hint": "Supports .tar.gz (max 1GB)",
"withdraw_btn": "Withdraw", "withdraw_btn": "Withdraw",
@@ -23,6 +23,12 @@
"context_ref_placeholder": "例如: minio/contexts/my-modpack.tar.gz", "context_ref_placeholder": "例如: minio/contexts/my-modpack.tar.gz",
"download_context_btn": "下载上下文", "download_context_btn": "下载上下文",
"download_context_hint": "下载上传的构建上下文 (.tar.gz)——其中包含将被实际执行的 Dockerfile。", "download_context_hint": "下载上传的构建上下文 (.tar.gz)——其中包含将被实际执行的 Dockerfile。",
"context_sha256_label": "上下文 SHA-256",
"context_sha256_hint": "通过会绑定这个摘要:提交者之后再上传,构建会拒绝执行。离线审阅时请用 sha256sum 核对下载的文件。",
"context_sha256_missing": "尚未上传构建上下文,或上传早于摘要记录,需要提交者重新上传。",
"context_sha256_downloaded": "与你刚下载的文件一致,通过会绑定这份内容。",
"context_sha256_stale": "你下载的版本({{digest}}…)已被新的上传替换,请重新下载审阅。",
"approve_needs_context": "没有可通过的上下文:提交者尚未上传或需要重新上传",
"base_image_label": "基础镜像", "base_image_label": "基础镜像",
"base_image_placeholder": "例如: library/postgres:15", "base_image_placeholder": "例如: library/postgres:15",
"view_logs_btn": "日志", "view_logs_btn": "日志",
@@ -52,6 +52,7 @@
"build_unavailable": "构建功能当前不可用。", "build_unavailable": "构建功能当前不可用。",
"build_logs_unavailable": "构建日志暂时不可用。", "build_logs_unavailable": "构建日志暂时不可用。",
"already_reviewed": "该提交已经审核过了。", "already_reviewed": "该提交已经审核过了。",
"context_changed": "提交者在你审阅之后重新上传了构建上下文。请重新下载并审阅新的上传再通过。",
"submission_quota_exceeded": "你的提交配额已满:待审核提交过多,或已存上传总量达到上限。", "submission_quota_exceeded": "你的提交配额已满:待审核提交过多,或已存上传总量达到上限。",
"submission_cooldown": "操作太频繁——请稍后再试。", "submission_cooldown": "操作太频繁——请稍后再试。",
"submissions_unavailable": "提交流程当前不可用。", "submissions_unavailable": "提交流程当前不可用。",
@@ -35,6 +35,8 @@
"error_file_required": "必须上传构建上下文文件。", "error_file_required": "必须上传构建上下文文件。",
"field_context_ref": "构建上下文引用", "field_context_ref": "构建上下文引用",
"field_image_ref": "目标镜像引用", "field_image_ref": "目标镜像引用",
"field_context_sha256": "上下文 SHA-256",
"field_context_sha256_hint": "平台收到的文件摘要,可用 sha256sum 与本地文件核对。审核通过的就是这份内容。",
"clear_btn": "清除", "clear_btn": "清除",
"file_hint": "支持 .tar.gz 格式 (最大 1GB)", "file_hint": "支持 .tar.gz 格式 (最大 1GB)",
"withdraw_btn": "撤回提交", "withdraw_btn": "撤回提交",
+24 -1
View File
@@ -473,11 +473,34 @@ describe("image whitelist and builds wire shapes", () => {
const sub = { id: "sub-1", status: "approved" }; const sub = { id: "sub-1", status: "approved" };
const fetchSpy = fakeFetch(sub); const fetchSpy = fakeFetch(sub);
vi.stubGlobal("fetch", fetchSpy); vi.stubGlobal("fetch", fetchSpy);
const res = await api.approveSubmission("sub-1"); const digest = "a".repeat(64);
const res = await api.approveSubmission("sub-1", digest);
expect(res).toEqual(sub); expect(res).toEqual(sub);
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0]; const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(String(url)).toBe("/submissions/sub-1/approve"); expect(String(url)).toBe("/submissions/sub-1/approve");
expect((opts as RequestInit).method).toBe("POST"); expect((opts as RequestInit).method).toBe("POST");
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({ expected_digest: digest });
});
it("downloadSubmissionContext resolves to the digest the API streamed", async () => {
const digest = "b".repeat(64);
const fetchSpy = vi.fn(async () => ({
ok: true,
status: 200,
statusText: "OK",
headers: new Headers({ "X-Felis-Context-Sha256": digest.toUpperCase() }),
blob: async () => new Blob(["ctx"]),
})) as unknown as typeof fetch;
vi.stubGlobal("fetch", fetchSpy);
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:ctx");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
const link = { href: "", download: "", click: vi.fn() };
vi.stubGlobal("document", { createElement: () => link });
await expect(api.downloadSubmissionContext("sub-1")).resolves.toBe(digest);
expect(link.click).toHaveBeenCalledOnce();
expect(link.download).toBe("sub-1-context.tar.gz");
const [url] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(String(url)).toBe("/submissions/sub-1/context");
}); });
it("rejectSubmission POSTs {reason} to /submissions/{id}/reject", async () => { it("rejectSubmission POSTs {reason} to /submissions/{id}/reject", async () => {
+11 -3
View File
@@ -492,8 +492,10 @@ export const api = {
listSubmissions: () => listSubmissions: () =>
request<{ submissions: Submission[] }>("GET", "/submissions").then((r) => r.submissions ?? []), request<{ submissions: Submission[] }>("GET", "/submissions").then((r) => r.submissions ?? []),
approveSubmission: (id: string) => // expectedDigest is the sha256 of the context the reviewer looked at; the API
request<Submission>("POST", `/submissions/${id}/approve`), // refuses the approval (409 context_changed) when the upload has since changed.
approveSubmission: (id: string, expectedDigest: string) =>
request<Submission>("POST", `/submissions/${id}/approve`, { expected_digest: expectedDigest }),
rejectSubmission: (id: string, reason: string) => rejectSubmission: (id: string, reason: string) =>
request<Submission>("POST", `/submissions/${id}/reject`, { reason }), request<Submission>("POST", `/submissions/${id}/reject`, { reason }),
@@ -506,7 +508,9 @@ export const api = {
// Dockerfile lives inside the tarball, so approving without this would be // Dockerfile lives inside the tarball, so approving without this would be
// blind. The body is the attacker-supplied archive — download it, never // blind. The body is the attacker-supplied archive — download it, never
// render it — which the API's attachment disposition enforces. // render it — which the API's attachment disposition enforces.
downloadSubmissionContext: async (id: string): Promise<void> => { // Resolves to the sha256 the API vouched for while streaming these bytes (it
// aborts the transfer on a mismatch), so the approval can name what was read.
downloadSubmissionContext: async (id: string): Promise<string | null> => {
const { apiBase } = await loadConfig(); const { apiBase } = await loadConfig();
const res = await fetch(`${apiBase}/submissions/${id}/context`, { const res = await fetch(`${apiBase}/submissions/${id}/context`, {
method: "GET", method: "GET",
@@ -528,6 +532,7 @@ export const api = {
announceSetupRequired(err); announceSetupRequired(err);
throw err; throw err;
} }
const digest = res.headers.get("X-Felis-Context-Sha256")?.trim().toLowerCase() || null;
const blob = await res.blob(); const blob = await res.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement("a"); const link = document.createElement("a");
@@ -535,6 +540,7 @@ export const api = {
link.download = `${id}-context.tar.gz`; link.download = `${id}-context.tar.gz`;
link.click(); link.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
return digest;
}, },
listMySubmissions: () => listMySubmissions: () =>
@@ -788,6 +794,8 @@ export function humanizeError(e: unknown): string {
return t("build_logs_unavailable"); return t("build_logs_unavailable");
case "already_reviewed": case "already_reviewed":
return t("already_reviewed"); return t("already_reviewed");
case "context_changed":
return t("context_changed");
case "submission_quota_exceeded": case "submission_quota_exceeded":
return t("submission_quota_exceeded"); return t("submission_quota_exceeded");
case "submission_cooldown": case "submission_cooldown":
+2
View File
@@ -290,6 +290,8 @@ export interface Submission {
reject_reason?: string; reject_reason?: string;
created_at: string; created_at: string;
reviewed_at?: string; reviewed_at?: string;
/** sha256 of the uploaded context; approval must name it (migration 0025). */
context_sha256?: string;
} }
export interface UpdateWindow { export interface UpdateWindow {
+6
View File
@@ -430,6 +430,12 @@ export function MySubmissionsPage() {
<p className="font-semibold text-foreground mb-1">{t("field_context_ref")}</p> <p className="font-semibold text-foreground mb-1">{t("field_context_ref")}</p>
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all">{sub.context_ref}</pre> <pre className="font-mono bg-background border rounded p-1.5 truncate select-all">{sub.context_ref}</pre>
</div> </div>
{sub.context_sha256 && (
<div>
<p className="font-semibold text-foreground mb-1">{t("field_context_sha256")}</p>
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all" title={t("field_context_sha256_hint")}>{sub.context_sha256}</pre>
</div>
)}
{sub.image_ref && ( {sub.image_ref && (
<div> <div>
<p className="font-semibold text-foreground mb-1">{t("field_image_ref")}</p> <p className="font-semibold text-foreground mb-1">{t("field_image_ref")}</p>
+52 -10
View File
@@ -1,5 +1,5 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { ClipboardCheck, CheckCircle2, CircleSlash, ChevronDown, ChevronUp, Check, X, Loader2, Download, Trash2 } from "lucide-react"; import { ClipboardCheck, CheckCircle2, CircleSlash, ChevronDown, ChevronUp, Check, X, Loader2, Download, Trash2, ShieldCheck, TriangleAlert } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { StatCard } from "@/components/StatCard"; import { StatCard } from "@/components/StatCard";
@@ -24,7 +24,7 @@ import { Pagination } from "@/components/Pagination";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks"; import { useAsync } from "@/lib/hooks";
import { formatRelative, formatAbsolute } from "@/lib/format"; import { formatRelative, formatAbsolute } from "@/lib/format";
import type { Submission, SubmissionStatus } from "@/lib/types"; import type { ApiError, Submission, SubmissionStatus } from "@/lib/types";
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
@@ -46,6 +46,10 @@ export function SubmissionsPage() {
const [busyId, setBusyId] = useState<string | null>(null); const [busyId, setBusyId] = useState<string | null>(null);
const [busyType, setBusyType] = useState<"approve" | "reject" | "delete" | null>(null); const [busyType, setBusyType] = useState<"approve" | "reject" | "delete" | null>(null);
const [downloadingId, setDownloadingId] = useState<string | null>(null); const [downloadingId, setDownloadingId] = useState<string | null>(null);
// The sha256 of each context this reviewer downloaded in this session. An
// approval names the digest of what was actually read; the listed digest
// stands in when the review happened elsewhere (the CLI, an earlier session).
const [reviewedDigests, setReviewedDigests] = useState<Record<string, string>>({});
// Delete arms the row (trash → confirm/cancel) before it fires; a row gone on // Delete arms the row (trash → confirm/cancel) before it fires; a row gone on
// one stray click would take its uploaded context with it. // one stray click would take its uploaded context with it.
const [confirmingDelete, setConfirmingDelete] = useState<string | null>(null); const [confirmingDelete, setConfirmingDelete] = useState<string | null>(null);
@@ -101,16 +105,23 @@ export function SubmissionsPage() {
return filteredSubmissions.slice(start, start + PAGE_SIZE); return filteredSubmissions.slice(start, start + PAGE_SIZE);
}, [filteredSubmissions, page]); }, [filteredSubmissions, page]);
async function handleApprove(id: string) { async function handleApprove(sub: Submission) {
if (busyId) return; const digest = reviewedDigests[sub.id] ?? sub.context_sha256;
setBusyId(id); if (busyId || !digest) return;
setBusyId(sub.id);
setBusyType("approve"); setBusyType("approve");
setActionError(null); setActionError(null);
try { try {
await api.approveSubmission(id); await api.approveSubmission(sub.id, digest);
reload(); reload();
} catch (err) { } catch (err) {
setActionError(humanizeError(err)); setActionError(humanizeError(err));
// A newer upload replaced what was reviewed: forget the stale download and
// show the new digest, so the next approval has to be a fresh review.
if ((err as Partial<ApiError>).code === "context_changed") {
setReviewedDigests(({ [sub.id]: _stale, ...rest }) => rest);
reload();
}
} finally { } finally {
setBusyId(null); setBusyId(null);
setBusyType(null); setBusyType(null);
@@ -168,7 +179,13 @@ export function SubmissionsPage() {
setActionError(null); setActionError(null);
setDownloadingId(sub.id); setDownloadingId(sub.id);
try { try {
await api.downloadSubmissionContext(sub.id); const digest = await api.downloadSubmissionContext(sub.id);
if (digest) {
setReviewedDigests((prev) => ({ ...prev, [sub.id]: digest }));
// The list predates a re-upload: refresh it so the page shows what was
// just downloaded.
if (digest !== sub.context_sha256) reload();
}
} catch (err) { } catch (err) {
setActionError(humanizeError(err)); setActionError(humanizeError(err));
} finally { } finally {
@@ -291,6 +308,8 @@ export function SubmissionsPage() {
const isBusyApprove = busyId === sub.id && busyType === "approve"; const isBusyApprove = busyId === sub.id && busyType === "approve";
const isBusyReject = busyId === sub.id && busyType === "reject"; const isBusyReject = busyId === sub.id && busyType === "reject";
const isBusyDelete = busyId === sub.id && busyType === "delete"; const isBusyDelete = busyId === sub.id && busyType === "delete";
const reviewedDigest = reviewedDigests[sub.id];
const approveDigest = reviewedDigest ?? sub.context_sha256;
return ( return (
<div key={sub.id} className="flex flex-col"> <div key={sub.id} className="flex flex-col">
<div <div
@@ -342,9 +361,9 @@ export function SubmissionsPage() {
size="icon" size="icon"
variant="outline" variant="outline"
className="h-7 w-7 text-emerald-500 hover:text-emerald-600 border-emerald-500/20 hover:bg-emerald-500/10 focus-visible:ring-emerald-500" className="h-7 w-7 text-emerald-500 hover:text-emerald-600 border-emerald-500/20 hover:bg-emerald-500/10 focus-visible:ring-emerald-500"
onClick={() => handleApprove(sub.id)} onClick={() => handleApprove(sub)}
disabled={!!busyId} disabled={!!busyId || !approveDigest}
title={t("approve_btn")} title={approveDigest ? t("approve_btn") : t("approve_needs_context")}
> >
{isBusyApprove ? ( {isBusyApprove ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
@@ -423,6 +442,29 @@ export function SubmissionsPage() {
</Button> </Button>
</div> </div>
</div> </div>
<div>
<p className="font-semibold text-foreground mb-1">{t("context_sha256_label")}</p>
{sub.context_sha256 ? (
<pre className="font-mono bg-background border rounded p-1.5 truncate select-all" title={t("context_sha256_hint")}>{sub.context_sha256}</pre>
) : (
<p className="text-amber-600 dark:text-amber-400">{t("context_sha256_missing")}</p>
)}
{reviewedDigest && reviewedDigest === sub.context_sha256 && (
<p className="mt-1 flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
{t("context_sha256_downloaded")}
</p>
)}
{reviewedDigest && sub.context_sha256 && reviewedDigest !== sub.context_sha256 && (
<p className="mt-1 flex items-start gap-1 text-amber-600 dark:text-amber-400">
<TriangleAlert className="h-3.5 w-3.5 shrink-0 mt-px" />
<span>{t("context_sha256_stale", { digest: reviewedDigest.slice(0, 12) })}</span>
</p>
)}
{sub.status === "pending_review" && sub.context_sha256 && !reviewedDigest && (
<p className="mt-1 text-muted-foreground/80">{t("context_sha256_hint")}</p>
)}
</div>
{sub.image_ref && ( {sub.image_ref && (
<div> <div>
<p className="font-semibold text-foreground mb-1">{t("image_ref_label")}</p> <p className="font-semibold text-foreground mb-1">{t("image_ref_label")}</p>