Unverified Commit e521cf59 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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

parent 13b65e19
Loading
Loading
Loading
Loading
+3 −0
Changes for cmd/felis/api.go: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -469,6 +469,9 @@ func buildConfig(cfg *config.Config) build.Config {
		// the internal DB mirror (see config.RegistryConfig.TrivyDBRepository).
		TrivyDBRepository:     cfg.Registry.TrivyDBRepository,
		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
Changes for cmd/felis/fetchcontext.go: 34 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -4,6 +4,8 @@ import (
	"archive/tar"
	"compress/gzip"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"flag"
	"fmt"
@@ -15,6 +17,8 @@ import (
	"strings"
	"syscall"
	"time"

	"felis.lolicon.best/internal/build"
)

// 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)
	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")
	want := fs.String("sha256", "", "refuse the context unless the tarball's sha256 is this lowercase hex digest")
	if err := fs.Parse(args); err != nil {
		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 == "" {
		fmt.Fprintln(stderr, "felis fetch-context: --url is required")
		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
	// Job's activeDeadlineSeconds is the real bound. The header timeout catches a
	// 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)
	if err != nil {
		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()

	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)
		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
}

+65 −0
Changes for cmd/felis/fetchcontext_test.go: 65 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -4,6 +4,8 @@ import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net"
	"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
// an empty (or partial) context Kaniko would then try to build.
func TestExtractTarGzRejectsNonGzip(t *testing.T) {
+20 −0
Changes for docs/troubleshooting.md: 20 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -572,6 +572,7 @@ approved-but-hostile Dockerfile and the node is the pod around it:
| Layer | What it does | Where |
|---|---|---|
| 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 |
| 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` |
@@ -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` |
| 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
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
+5 −0
Changes for internal/api/middleware.go: 5 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -70,6 +70,11 @@ func withRecover(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			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",
					r.Method, r.URL.Path, requestIDFromContext(r.Context()), rec, debug.Stack())
				writeError(w, r, newError(http.StatusInternalServerError, "panic", "internal error"))
Loading