feat(api,panel): reviewer context download for submissions; dockerfile field documented as audit-only (audit #45)

This commit is contained in:
Lemon-miaow committed 2026-09-23 16:41:55 +08:00
1 parent edd9d63f5e
commit 168a37542b
9 files changed
+213 -9

No files matched your search

+3
View File
@@ -521,6 +521,9 @@ func (a *API) externalAPIRoutes() []apiRoute {
{Method: "GET", Pattern: "/api/v1/submissions", Admin: true, h: a.handleListSubmissions},
{Method: "POST", Pattern: "/api/v1/submissions/{id}/approve", Admin: true, h: a.handleApproveSubmission},
{Method: "POST", Pattern: "/api/v1/submissions/{id}/reject", Admin: true, h: a.handleRejectSubmission},
// The reviewer's read path to the uploaded blob: the executed Dockerfile
// lives inside it, so approval would otherwise be blind.
{Method: "GET", Pattern: "/api/v1/submissions/{id}/context", Admin: true, h: a.handleAdminSubmissionContext},
// Auto-update maintenance window (spec §B; decision core internal/updates).
// Admin-tier: it governs whether Felis may apply an update to itself, so setting
// it requires the admin Zero-Trust path, not a mere session. API+persistence
+39 -2
View File
@@ -286,15 +286,52 @@ func writeSubmitError(w http.ResponseWriter, r *http.Request, err error) {
// fetcher extracts it under a zip-slip guard, and Kaniko treats the result as
// hostile regardless (spec §16).
func (a *API) handleInternalSubmissionContext(w http.ResponseWriter, r *http.Request) {
rc, ok := a.openSubmissionContext(w, r)
if !ok {
return
}
streamSubmissionContext(w, rc)
}
// handleAdminSubmissionContext streams a submission's stored build-context
// tarball to a reviewing admin (admin-tier). Review is only a real gate if the
// reviewer can inspect what they approve: the executed Dockerfile lives INSIDE
// this tarball (build/jobspec.go pins --dockerfile=Dockerfile), so without this
// route the human gate could not see the recipe at all. The bytes are the same
// ones the build Pod fetches over the internal face; the attachment disposition
// makes the browser download the attacker-supplied archive, never render it.
func (a *API) handleAdminSubmissionContext(w http.ResponseWriter, r *http.Request) {
rc, ok := a.openSubmissionContext(w, r)
if !ok {
return
}
w.Header().Set("Content-Disposition", `attachment; filename="context.tar.gz"`)
w.Header().Set("X-Content-Type-Options", "nosniff")
a.audit(r, principalFromContext(r.Context()).Email, "submission.context.download", r.PathValue("id"))
streamSubmissionContext(w, rc)
}
// openSubmissionContext resolves the build-context blob named in the request
// 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
// is already written and the caller must return.
func (a *API) openSubmissionContext(w http.ResponseWriter, r *http.Request) (io.ReadCloser, bool) {
if a.Submissions == nil {
writeError(w, r, errSubmissionsUnavailable)
return
return nil, false
}
rc, err := a.Submissions.OpenContext(r.Context(), r.PathValue("id"))
if err != nil {
writeSubmitError(w, r, err)
return
return nil, false
}
return rc, true
}
// streamSubmissionContext copies the blob to w verbatim and closes it. The
// caller must have set every header already: the copy commits the response, so
// a failure mid-stream can only truncate it.
func streamSubmissionContext(w http.ResponseWriter, rc io.ReadCloser) {
defer rc.Close()
w.Header().Set("Content-Type", "application/gzip")
if _, err := io.Copy(w, rc); err != nil {
+44
View File
@@ -342,6 +342,7 @@ func TestSubmissionAdminRoutesAreAdminOnly(t *testing.T) {
{"GET", "/api/v1/submissions", ""},
{"POST", "/api/v1/submissions/sub-1/approve", ""},
{"POST", "/api/v1/submissions/sub-1/reject", `{"reason":"no"}`},
{"GET", "/api/v1/submissions/sub-1/context", ""},
}
for _, c := range cases {
api := adminSubAPI(&fakeSubmissions{})
@@ -523,3 +524,46 @@ func TestInternalSubmissionContextRoute(t *testing.T) {
}
})
}
// The admin context route is the reviewer's read path to the blob they are
// approving: an admin streams the stored bytes with a download disposition,
// and the error mapping matches the internal route (404 missing, 503 unwired).
// The admin-only gate itself is pinned by TestSubmissionAdminRoutesAreAdminOnly.
func TestAdminSubmissionContextRoute(t *testing.T) {
t.Run("streams the blob with a download disposition", func(t *testing.T) {
fs := &fakeSubmissions{openBody: "\x1f\x8b\x08\x00blob"}
w := do(adminSubAPI(fs).ExternalHandler(), "GET", "/api/v1/submissions/sub-7/context", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
if w.Body.String() != fs.openBody {
t.Fatalf("body = %q, want the stored blob %q", w.Body.String(), fs.openBody)
}
if fs.openedID != "sub-7" {
t.Fatalf("opened id = %q, want the path id", fs.openedID)
}
if ct := w.Header().Get("Content-Type"); ct != "application/gzip" {
t.Fatalf("content-type = %q, want application/gzip", ct)
}
if cd := w.Header().Get("Content-Disposition"); cd != `attachment; filename="context.tar.gz"` {
t.Fatalf("content-disposition = %q", cd)
}
})
t.Run("missing blob is 404", func(t *testing.T) {
fs := &fakeSubmissions{openErr: fmt.Errorf("%w: gone", submit.ErrBlobNotFound)}
w := do(adminSubAPI(fs).ExternalHandler(), "GET", "/api/v1/submissions/sub-7/context", "", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", w.Code)
}
})
t.Run("unwired transport is 503", func(t *testing.T) {
api := adminSubAPI(nil)
api.Submissions = nil
w := do(api.ExternalHandler(), "GET", "/api/v1/submissions/sub-7/context", "", nil)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("code = %d, want 503", w.Code)
}
})
}