220 lines
8.0 KiB
Go
220 lines
8.0 KiB
Go
package build
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"felis.lolicon.best/internal/registrygate"
|
|
)
|
|
|
|
// invalidf builds a validation error wrapping ErrInvalid so the API layer maps
|
|
// every malformed-request case to a single 400 path.
|
|
func invalidf(format string, a ...any) error {
|
|
return fmt.Errorf("%w: "+format, append([]any{ErrInvalid}, a...)...)
|
|
}
|
|
|
|
// imageNameRE matches the path+tag of an image reference under the registry
|
|
// host, e.g. "foo/bar:1.0" or "mc-paper:latest". It is intentionally strict:
|
|
// lowercase path segments, an optional tag of the same alphabet, no digests, no
|
|
// shell metacharacters that could escape into the Kaniko/Trivy argv.
|
|
var imageNameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9._/-]*[a-z0-9])?(:[a-zA-Z0-9._-]+)?$`)
|
|
|
|
// Validate enforces the §16 admission rules on a build request: the target must
|
|
// address the internal registry, the Dockerfile must be present and within the
|
|
// size cap, and a context reference is required (Kaniko pulls it, spec §17).
|
|
func Validate(req Request, cfg Config) error {
|
|
cfg = cfg.withDefaults()
|
|
if err := validateRegistryTarget(req.ImageRef, cfg.RegistryURL); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(req.Dockerfile) == "" {
|
|
return invalidf("dockerfile is required")
|
|
}
|
|
if len(req.Dockerfile) > cfg.MaxDockerfileBytes {
|
|
return invalidf("dockerfile exceeds %d bytes", cfg.MaxDockerfileBytes)
|
|
}
|
|
if strings.TrimSpace(req.ContextRef) == "" {
|
|
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
|
|
}
|
|
|
|
// 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,
|
|
// where there is no registry-target constraint beyond well-formedness). A
|
|
// whitelist entry may be a tag wildcard ("registry/foo:*", a legitimate
|
|
// image_whitelist value per spec §15): the trailing ":*" is stripped before the
|
|
// path is validated. This is safe because a wildcard is only ever compared
|
|
// against concrete refs by imageMatches — it never reaches a Kaniko/Trivy argv,
|
|
// unlike a build push target (validateRegistryTarget stays strictly concrete).
|
|
func ValidateImageRef(ref string) error {
|
|
if ref == "" {
|
|
return invalidf("image reference is required")
|
|
}
|
|
host, rest, ok := splitRegistryHost(ref)
|
|
if !ok {
|
|
return invalidf("image reference %q must be host-qualified (host/path:tag)", ref)
|
|
}
|
|
if repo, isWildcard := strings.CutSuffix(rest, ":*"); isWildcard {
|
|
rest = repo
|
|
}
|
|
if !imageNameRE.MatchString(rest) {
|
|
return invalidf("invalid image path/tag %q", rest)
|
|
}
|
|
_ = host
|
|
return nil
|
|
}
|
|
|
|
// splitTag splits an image reference's path from its tag. It is registry-port
|
|
// safe: only a colon *after* the final path separator is a tag separator, so
|
|
// "registry:5000/foo" splits to ("registry:5000/foo", ""), never a bogus tag.
|
|
func splitTag(ref string) (repo, tag string) {
|
|
slash := strings.LastIndexByte(ref, '/')
|
|
colon := strings.LastIndexByte(ref, ':')
|
|
if colon > slash {
|
|
return ref[:colon], ref[colon+1:]
|
|
}
|
|
return ref, ""
|
|
}
|
|
|
|
// imageMatches reports whether a concrete image ref is admitted by a whitelist
|
|
// pattern. A pattern is either exact ("registry/foo:1.0") or a tag wildcard
|
|
// ("registry/foo:*", spec §15) that matches any non-empty tag on the same repo.
|
|
// A wildcard never matches an untagged ref — admission is always to a concrete
|
|
// tag.
|
|
func imageMatches(ref, pattern string) bool {
|
|
if ref == pattern {
|
|
return true
|
|
}
|
|
prefix, ok := strings.CutSuffix(pattern, ":*")
|
|
if !ok {
|
|
return false
|
|
}
|
|
repo, tag := splitTag(ref)
|
|
return repo == prefix && tag != ""
|
|
}
|
|
|
|
// validateRegistryTarget enforces that a build pushes only to the configured
|
|
// internal registry — never an arbitrary external host (spec §16: the build can
|
|
// never push elsewhere; the registry is not a public ingress). When RegistryURL
|
|
// is unset (tests / not configured) the host constraint is skipped but the
|
|
// path/tag are still validated.
|
|
func validateRegistryTarget(ref, registryURL string) error {
|
|
if ref == "" {
|
|
return invalidf("image reference is required")
|
|
}
|
|
host, rest, ok := splitRegistryHost(ref)
|
|
if !ok {
|
|
return invalidf("image reference %q must target the internal registry (host/path:tag)", ref)
|
|
}
|
|
if !imageNameRE.MatchString(rest) {
|
|
return invalidf("invalid image path/tag %q", rest)
|
|
}
|
|
if registryURL != "" && host != registryHost(registryURL) {
|
|
return invalidf("image reference %q must target the internal registry %q, not %q",
|
|
ref, registryHost(registryURL), host)
|
|
}
|
|
// The registry gate refuses the build principal these repositories anyway
|
|
// (they hold the platform's own images and the scanner's DB mirrors); refusing
|
|
// here turns a build that would fail at its last step into a 400 up front.
|
|
root, _, _ := strings.Cut(rest, "/")
|
|
for _, reserved := range registrygate.ReservedRepoRoots {
|
|
if root == reserved || strings.HasPrefix(root, reserved+":") {
|
|
return invalidf("image reference %q is in %s/, which is reserved for the platform's own images", ref, reserved)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// splitRegistryHost separates the registry host from the remaining path+tag. A
|
|
// reference is host-qualified only if the first segment looks like a registry
|
|
// host — it contains a '.' or ':' (port), matching containerd's heuristic.
|
|
// "foo/bar:1" (Docker Hub shorthand) is rejected: builds must be explicit about
|
|
// the internal registry.
|
|
func splitRegistryHost(ref string) (host, rest string, ok bool) {
|
|
slash := strings.IndexByte(ref, '/')
|
|
if slash < 0 {
|
|
return "", "", false
|
|
}
|
|
host = ref[:slash]
|
|
rest = ref[slash+1:]
|
|
if !strings.ContainsAny(host, ".:") {
|
|
return "", "", false
|
|
}
|
|
if rest == "" {
|
|
return "", "", false
|
|
}
|
|
return host, rest, true
|
|
}
|
|
|
|
// registryHost strips any scheme and path from a configured registry URL,
|
|
// leaving the host[:port] that an image reference must match.
|
|
func registryHost(registryURL string) string {
|
|
h := registryURL
|
|
if i := strings.Index(h, "://"); i >= 0 {
|
|
h = h[i+3:]
|
|
}
|
|
if i := strings.IndexByte(h, '/'); i >= 0 {
|
|
h = h[:i]
|
|
}
|
|
return h
|
|
}
|