package api import ( "archive/tar" "bytes" "context" "encoding/json" "errors" "io" "net/url" "os" "os/exec" "path" "regexp" "strings" "syscall" "time" ) type releaseManifest struct { SchemaVersion int `json:"schema_version"` ReleaseID string `json:"release_id"` Version string `json:"version"` DeploymentSchema int `json:"deployment_schema"` Platforms []string `json:"platforms"` } type releaseInfo struct { releaseManifest Repository string `json:"source_repository"` Commit string `json:"source_commit"` } var errReleaseIncompatible = errors.New("release layout or platform is incompatible") var releaseCommit = regexp.MustCompile(`^[0-9a-f]{40}$`) var releaseID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) const releaseLimit = 32 << 20 func releaseSource() (string, string, error) { repository := os.Getenv("DSH_RELEASE_REPOSITORY") if repository == "" { repository = "https://git.misaka.ren/m1saka/dsh-web.git" } branch := os.Getenv("DSH_RELEASE_BRANCH") if branch == "" { branch = "dockerfile" } u, err := url.Parse(repository) if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.ContainsAny(repository, "\r\n") || !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`).MatchString(branch) || strings.Contains(branch, "..") { return "", "", errors.New("invalid trusted release source configuration") } return repository, branch, nil } type boundedReleaseBuffer struct{ bytes.Buffer } func (b *boundedReleaseBuffer) Write(p []byte) (int, error) { if b.Len()+len(p) > releaseLimit { return 0, errors.New("release exceeds size limit") } return b.Buffer.Write(p) } func gitReleaseCommand(ctx context.Context, dir string, args ...string) ([]byte, error) { gitArgs := append([]string{"-c", "credential.helper=", "-c", "http.followRedirects=false", "-c", "core.hooksPath=/dev/null", "-c", "protocol.allow=never", "-c", "protocol.https.allow=always", "-c", "fetch.unpackLimit=1"}, args...) // The inherited file-size limit also bounds Git's on-disk pack, not just stdout. cmd := exec.CommandContext(ctx, "/bin/sh", append([]string{"-c", "ulimit -f 32768; exec git \"$@\"", "git"}, gitArgs...)...) cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } cmd.Dir = dir cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_TERMINAL_PROMPT=0", "GIT_LFS_SKIP_SMUDGE=1"} cmd.WaitDelay = time.Second var out boundedReleaseBuffer cmd.Stdout = &out if err := cmd.Run(); err != nil { return nil, errors.New("trusted release Git operation failed") } return out.Bytes(), nil } var releaseFetchSlot = make(chan struct{}, 1) func readRelease(ctx context.Context, repository, ref string) (releaseInfo, io.Reader, error) { var info releaseInfo ctx, cancel := context.WithTimeout(ctx, 45*time.Second) defer cancel() select { case releaseFetchSlot <- struct{}{}: defer func() { <-releaseFetchSlot }() case <-ctx.Done(): return info, nil, ctx.Err() } dir, err := os.MkdirTemp("", "dsh-release-") if err != nil { return info, nil, err } defer os.RemoveAll(dir) if _, err = gitReleaseCommand(ctx, dir, "init", "--quiet"); err != nil { return info, nil, err } if _, err = gitReleaseCommand(ctx, dir, "fetch", "--quiet", "--depth=1", "--no-tags", repository, ref); err != nil { return info, nil, err } commit, err := gitReleaseCommand(ctx, dir, "rev-parse", "FETCH_HEAD^{commit}") if err != nil { return info, nil, err } info.Commit = strings.TrimSpace(string(commit)) info.Repository = repository if !releaseCommit.MatchString(info.Commit) || (releaseCommit.MatchString(ref) && info.Commit != ref) { return info, nil, errors.New("release commit mismatch") } archive, err := gitReleaseCommand(ctx, dir, "archive", "--format=tar", info.Commit, "--", "release.json", "image") if err != nil { return info, nil, err } return parseReleaseArchive(info, archive) } func parseReleaseArchive(info releaseInfo, archive []byte) (releaseInfo, io.Reader, error) { var manifest []byte tr := tar.NewReader(bytes.NewReader(archive)) for { h, err := tr.Next() if err == io.EOF { break } if err != nil { return info, nil, err } if h.Name == "release.json" && h.Typeflag == tar.TypeReg { if h.Size > 16384 { return info, nil, errors.New("release manifest too large") } manifest, err = io.ReadAll(tr) if err != nil { return info, nil, err } } } dec := json.NewDecoder(bytes.NewReader(manifest)) dec.DisallowUnknownFields() if dec.Decode(&info.releaseManifest) != nil || dec.Decode(new(any)) != io.EOF { return info, nil, errors.New("invalid release manifest") } m := info.releaseManifest compatible := false for _, p := range m.Platforms { if p == imagePlatform { compatible = true } } if m.SchemaVersion != 1 || !releaseID.MatchString(m.ReleaseID) || !validBuildVersion(m.Version) || m.DeploymentSchema < 1 || len(m.Platforms) == 0 { return info, nil, errors.New("unsupported release manifest or platform") } if m.DeploymentSchema != 7 || !compatible { return info, nil, errReleaseIncompatible } var out bytes.Buffer tw := tar.NewWriter(&out) tr = tar.NewReader(bytes.NewReader(archive)) dockerfile := false for { h, err := tr.Next() if err == io.EOF { break } if err != nil { return info, nil, err } name := h.Name if !strings.HasPrefix(name, "image/") { continue } name = strings.TrimPrefix(name, "image/") if name == "" { continue } if path.IsAbs(name) || path.Clean(strings.TrimSuffix(name, "/")) != strings.TrimSuffix(name, "/") || name == ".." || strings.HasPrefix(name, "../") { return info, nil, errors.New("unsafe release context path") } excluded := false for _, part := range strings.Split(name, "/") { if part == ".git" || part == ".env" || strings.HasPrefix(part, ".env.") || part == ".netrc" || part == ".npmrc" || part == ".ssh" || part == ".git-credentials" { excluded = true } } if excluded { continue } if h.Typeflag != tar.TypeReg && h.Typeflag != tar.TypeDir { return info, nil, errors.New("release context links and special files are not supported") } h.Name = name h.PAXRecords = nil h.Uname = "" h.Gname = "" h.Uid = 0 h.Gid = 0 if name == "Dockerfile" && h.Typeflag == tar.TypeReg { dockerfile = true } if err = tw.WriteHeader(h); err != nil { return info, nil, err } if _, err = io.Copy(tw, tr); err != nil { return info, nil, err } } if !dockerfile { return info, nil, errors.New("release context Dockerfile missing") } if err := tw.Close(); err != nil { return info, nil, err } return info, bytes.NewReader(out.Bytes()), nil }