diff --git a/modules/httplib/serve.go b/modules/httplib/serve.go index 8abf6f1887..89e81f4e8c 100644 --- a/modules/httplib/serve.go +++ b/modules/httplib/serve.go @@ -54,14 +54,25 @@ func ServeSetHeaders(w http.ResponseWriter, opts ServeHeaderOptions) { header.Set("Content-Length", strconv.FormatInt(*opts.ContentLength, 10)) } - // Disable script execution of HTML/SVG files, since we serve the file from the same origin as Gitea server - header.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") - if strings.Contains(contentType, "application/pdf") { + // Served files share the Gitea origin, so we sandbox them to prevent privilege escalation. + // HTML artifacts may include JavaScript (e.g. coverage reports, genhtml output) so we allow + // scripts for text/html but keep the origin opaque by omitting allow-same-origin: scripts + // run in a null origin and cannot access Gitea cookies or the parent frame. connect-src + // 'none' blocks fetch/XHR so scripts cannot exfiltrate data or make authenticated API calls. + switch { + case strings.Contains(contentType, "text/html"): + // sandbox allow-scripts: scripts execute but in null origin (no allow-same-origin). + // default-src 'self' allows artifact sub-resources (sort.js, gcov.css, etc.) to load + // from the same preview/raw/* route without opening any external origin. + header.Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'none'; sandbox allow-scripts") + case strings.Contains(contentType, "application/pdf"): // no sandbox attribute for PDF as it breaks rendering in at least safari. this // should generally be safe as scripts inside PDF can not escape the PDF document // see https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion // HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context header.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") + default: + header.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") } if opts.Filename != "" && opts.ContentDisposition != "" { diff --git a/routers/web/devtest/mock_actions.go b/routers/web/devtest/mock_actions.go index 68b3d6dc23..8057cd6170 100644 --- a/routers/web/devtest/mock_actions.go +++ b/routers/web/devtest/mock_actions.go @@ -41,16 +41,91 @@ var mockActionsArtifactFiles = map[string][]mockArtifactFile{ }, "artifact-lcov-coverage": { { - Path: "coverage/index.html", - Content: "mock lcov coverage report", + Path: "coverage/index.html", + // Realistic genhtml-style report. CSS is inlined because the raw + // handler sniffs external .css files as text/plain, which browsers + // refuse to apply as a stylesheet. JS is intentionally blocked by + // the iframe sandbox="" (no allow-scripts). + Content: ` + + + +LCOV - Coverage Report + + + + +

Coverage Report

+ + + + + + + + + + + + + + + +
Directory/FileLinesCoverage
src/
  main.go120 / 13290.9 %
  util.go45 / 6075.0 %
  legacy.go12 / 3831.6 %
Total: 177 / 230 — 76.9 %
+

(JS sandboxed — column sorting disabled)

+ +`, }, { Path: "coverage/lcov.info", - Content: "TN:\nSF:mock.go\nend_of_record\n", + Content: "TN:\nSF:src/main.go\nDA:1,1\nDA:2,1\nDA:10,0\nend_of_record\nTN:\nSF:src/util.go\nDA:1,1\nDA:5,0\nend_of_record\n", }, { - Path: "coverage/summary.txt", - Content: "mock coverage summary", + Path: "coverage/main.go.html", + Content: ` + +main.go - Coverage + + + +

src/main.go

+ + + + + +
1func main() {
2 fmt.Println("hello")
3}
10 unusedFunc()
+ +`, }, }, "artifact-really-loooooooooooooooooooooooooooooooooooooooooooooooooooooooong": { @@ -456,13 +531,13 @@ func MockActionsArtifactPreviewRaw(ctx *context.Context) { artifactName := ctx.PathParam("artifact_name") files, ok := mockActionsArtifactFiles[artifactName] if !ok { - ctx.NotFound(nil) + actions.WritePreviewRawError(ctx, http.StatusNotFound, "artifact not found") return } selectedPath := actions.ChoosePreviewPath(mockArtifactFilePaths(files), actions.GetRequestedPreviewPath(ctx)) if selectedPath == "" { - ctx.NotFound(nil) + actions.WritePreviewRawError(ctx, http.StatusNotFound, "artifact file not found") return } @@ -474,7 +549,7 @@ func MockActionsArtifactPreviewRaw(ctx *context.Context) { } } if selectedFile == nil { - ctx.NotFound(nil) + actions.WritePreviewRawError(ctx, http.StatusNotFound, "artifact file not found") return } diff --git a/routers/web/repo/actions/view_artifact.go b/routers/web/repo/actions/view_artifact.go new file mode 100644 index 0000000000..905eec8c1f --- /dev/null +++ b/routers/web/repo/actions/view_artifact.go @@ -0,0 +1,614 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "archive/zip" + "compress/gzip" + "errors" + "fmt" + "html" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "sync" + "time" + + actions_model "code.gitea.io/gitea/models/actions" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/actions" + "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/typesniffer" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/util/filebuffer" + context_module "code.gitea.io/gitea/services/context" +) + +type ArtifactsViewItem struct { + Name string `json:"name"` + Size int64 `json:"size"` + Status string `json:"status"` + ExpiresUnix int64 `json:"expiresUnix"` +} + +type ArtifactPreviewFile struct { + Path string + Selected bool +} + +const ( + artifactPreviewV4ZipListCacheTTL = 10 * time.Minute + artifactPreviewV4ZipListCacheMaxEntries = 128 +) + +type artifactPreviewV4ZipListCacheEntry struct { + paths []string + expiresAt time.Time +} + +var artifactPreviewV4ZipListCache = struct { + mu sync.Mutex + entries map[string]artifactPreviewV4ZipListCacheEntry + order []string +}{ + entries: map[string]artifactPreviewV4ZipListCacheEntry{}, +} + +type readAtBySeeker struct { + rs io.ReadSeeker + mu sync.Mutex +} + +func (r *readAtBySeeker) ReadAt(p []byte, off int64) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if _, err := r.rs.Seek(off, io.SeekStart); err != nil { + return 0, err + } + n, err := io.ReadFull(r.rs, p) + if errors.Is(err, io.ErrUnexpectedEOF) { + return n, io.EOF + } + return n, err +} + +// resolveArtifactAttemptIDFromQuery resolves the run_attempt_id used to scope artifact lookups. +// If the `attempt` query parameter is present and valid, it returns the matching attempt's ID. +// Otherwise it falls back to run.LatestAttemptID, which is 0 only for legacy runs created before ActionRunAttempt existed. +func resolveArtifactAttemptIDFromQuery(ctx *context_module.Context, run *actions_model.ActionRun) (int64, error) { + if ctx.FormString("attempt") == "" { + return run.LatestAttemptID, nil + } + attemptNum := ctx.FormInt64("attempt") + if attemptNum <= 0 { + return 0, util.ErrNotExist + } + attempt, err := actions_model.GetRunAttemptByRunIDAndAttemptNum(ctx, run.ID, attemptNum) + if err != nil { + return 0, err + } + return attempt.ID, nil +} + +func getCurrentRunAndUploadedArtifacts(ctx *context_module.Context, artifactName string) (*actions_model.ActionRun, []*actions_model.ActionArtifact, bool) { + run := getCurrentRunByPathParam(ctx) + if ctx.Written() { + return nil, nil, false + } + + artifacts, err := db.Find[actions_model.ActionArtifact](ctx, actions_model.FindArtifactsOptions{ + RunID: run.ID, + ArtifactName: artifactName, + }) + if err != nil { + ctx.ServerError("FindArtifacts", err) + return nil, nil, false + } + if len(artifacts) == 0 { + ctx.HTTPError(http.StatusNotFound, "artifact not found") + return nil, nil, false + } + + for _, art := range artifacts { + if art.Status != actions_model.ArtifactStatusUploadConfirmed { + ctx.HTTPError(http.StatusNotFound, "artifact not found") + return nil, nil, false + } + } + + run.Repo = ctx.Repo.Repository + return run, artifacts, true +} + +func normalizeArtifactPreviewPath(path string) string { + path = util.PathJoinRelX(path) + if path == "." { + return "" + } + return path +} + +// GetRequestedPreviewPath reads the requested artifact preview path from a +// request, accepting either the trailing `/preview/raw/*` path segment or a +// `?path=` query parameter, and normalizes it to a safe relative path. +func GetRequestedPreviewPath(ctx *context_module.Context) string { + path := strings.TrimPrefix(ctx.PathParam("*"), "/") + if path == "" { + path = ctx.Req.URL.Query().Get("path") + } + return normalizeArtifactPreviewPath(path) +} + +func artifactPreviewFallbackPath(artifact *actions_model.ActionArtifact) string { + path := normalizeArtifactPreviewPath(artifact.ArtifactPath) + if path != "" { + return path + } + return artifact.ArtifactName +} + +// ChoosePreviewPath resolves the preview path to render. +// An empty `requested` means no path was specified, so the first file is selected as a default. +// A non-empty `requested` that is not present in `paths` returns "" so callers can 404 instead of silently swapping to a different file. +func ChoosePreviewPath(paths []string, requested string) string { + if len(paths) == 0 { + return "" + } + if requested == "" { + return paths[0] + } + if util.SliceContainsString(paths, requested) { + return requested + } + return "" +} + +func listPreviewPathsForLegacyArtifacts(artifacts []*actions_model.ActionArtifact) []string { + paths := make([]string, 0, len(artifacts)) + seen := make(map[string]struct{}, len(artifacts)) + for _, artifact := range artifacts { + path := artifactPreviewFallbackPath(artifact) + if _, ok := seen[path]; ok { + continue + } + seen[path] = struct{}{} + paths = append(paths, path) + } + sort.Strings(paths) + return paths +} + +func openArtifactV4ZipReader(artifact *actions_model.ActionArtifact) (storage.Object, *zip.Reader, error) { + f, err := storage.ActionsArtifacts.Open(artifact.StoragePath) + if err != nil { + return nil, nil, err + } + stat, err := f.Stat() + if err != nil { + _ = f.Close() + return nil, nil, err + } + + reader, err := zip.NewReader(&readAtBySeeker{rs: f}, stat.Size()) + if err != nil { + _ = f.Close() + return nil, nil, err + } + return f, reader, nil +} + +func listArtifactV4ZipFiles(reader *zip.Reader) ([]string, map[string]*zip.File) { + paths := make([]string, 0, len(reader.File)) + files := make(map[string]*zip.File, len(reader.File)) + for _, file := range reader.File { + if file.FileInfo().IsDir() { + continue + } + path := normalizeArtifactPreviewPath(file.Name) + if path == "" { + continue + } + if _, ok := files[path]; ok { + continue + } + files[path] = file + paths = append(paths, path) + } + sort.Strings(paths) + return paths, files +} + +func listPreviewPathsForV4Artifact(artifact *actions_model.ActionArtifact) ([]string, error) { + if paths, ok := getArtifactPreviewV4ZipListFromCache(artifact); ok { + return paths, nil + } + + obj, reader, err := openArtifactV4ZipReader(artifact) + if err != nil { + if errors.Is(err, zip.ErrFormat) { + paths := []string{artifactPreviewFallbackPath(artifact)} + setArtifactPreviewV4ZipListCache(artifact, paths) + return paths, nil + } + return nil, err + } + defer obj.Close() + + paths, _ := listArtifactV4ZipFiles(reader) + setArtifactPreviewV4ZipListCache(artifact, paths) + return paths, nil +} + +func artifactPreviewV4ZipListCacheKey(artifact *actions_model.ActionArtifact) string { + return strconv.FormatInt(artifact.ID, 10) + ":" + strconv.FormatInt(int64(artifact.UpdatedUnix), 10) +} + +func removeArtifactPreviewV4ZipListCacheOrderKey(order []string, key string) []string { + for i, current := range order { + if current != key { + continue + } + return append(order[:i], order[i+1:]...) + } + return order +} + +func getArtifactPreviewV4ZipListFromCache(artifact *actions_model.ActionArtifact) ([]string, bool) { + key := artifactPreviewV4ZipListCacheKey(artifact) + + artifactPreviewV4ZipListCache.mu.Lock() + entry, ok := artifactPreviewV4ZipListCache.entries[key] + if !ok { + artifactPreviewV4ZipListCache.mu.Unlock() + return nil, false + } + if time.Now().After(entry.expiresAt) { + delete(artifactPreviewV4ZipListCache.entries, key) + artifactPreviewV4ZipListCache.order = removeArtifactPreviewV4ZipListCacheOrderKey(artifactPreviewV4ZipListCache.order, key) + artifactPreviewV4ZipListCache.mu.Unlock() + return nil, false + } + paths := append([]string(nil), entry.paths...) + artifactPreviewV4ZipListCache.mu.Unlock() + return paths, true +} + +func setArtifactPreviewV4ZipListCache(artifact *actions_model.ActionArtifact, paths []string) { + key := artifactPreviewV4ZipListCacheKey(artifact) + + artifactPreviewV4ZipListCache.mu.Lock() + defer artifactPreviewV4ZipListCache.mu.Unlock() + + if _, ok := artifactPreviewV4ZipListCache.entries[key]; ok { + artifactPreviewV4ZipListCache.order = removeArtifactPreviewV4ZipListCacheOrderKey(artifactPreviewV4ZipListCache.order, key) + } + artifactPreviewV4ZipListCache.order = append(artifactPreviewV4ZipListCache.order, key) + artifactPreviewV4ZipListCache.entries[key] = artifactPreviewV4ZipListCacheEntry{ + paths: append([]string(nil), paths...), + expiresAt: time.Now().Add(artifactPreviewV4ZipListCacheTTL), + } + + for len(artifactPreviewV4ZipListCache.entries) > artifactPreviewV4ZipListCacheMaxEntries && len(artifactPreviewV4ZipListCache.order) > 0 { + oldestKey := artifactPreviewV4ZipListCache.order[0] + artifactPreviewV4ZipListCache.order = artifactPreviewV4ZipListCache.order[1:] + if _, ok := artifactPreviewV4ZipListCache.entries[oldestKey]; !ok { + continue + } + delete(artifactPreviewV4ZipListCache.entries, oldestKey) + } +} + +func listPreviewPaths(artifacts []*actions_model.ActionArtifact) ([]string, error) { + if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) { + return listPreviewPathsForV4Artifact(artifacts[0]) + } + return listPreviewPathsForLegacyArtifacts(artifacts), nil +} + +func isPreviewableArtifactType(st typesniffer.SniffedType) bool { + return st.IsText() || st.IsPDF() +} + +// WritePreviewRawError writes a minimal self-contained HTML error page directly to the response, +// bypassing Gitea's template system so the full Gitea UI is never rendered inside the preview iframe. +func WritePreviewRawError(ctx *context_module.Context, status int, msg string) { + ctx.Resp.Header().Set("Content-Type", "text/html; charset=utf-8") + ctx.Resp.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") + ctx.Resp.Header().Set("X-Content-Type-Options", "nosniff") + ctx.Resp.WriteHeader(status) + _, _ = fmt.Fprintf(ctx.Resp, ` + +

%s

`, html.EscapeString(msg)) +} + +func previewArtifactByReader(ctx *context_module.Context, path string, reader io.Reader) { + buf := filebuffer.New(int(setting.UI.MaxDisplayFileSize), "") + defer buf.Close() + if _, err := io.Copy(buf, io.LimitReader(reader, setting.UI.MaxDisplayFileSize)); err != nil { + log.Error("artifact preview io.Copy: %v", err) + WritePreviewRawError(ctx, http.StatusInternalServerError, "failed to read artifact") + return + } + if _, err := buf.Seek(0, io.SeekStart); err != nil { + log.Error("artifact preview Seek: %v", err) + WritePreviewRawError(ctx, http.StatusInternalServerError, "failed to read artifact") + return + } + previewArtifactByReadSeeker(ctx, path, buf) +} + +func previewArtifactByReadSeeker(ctx *context_module.Context, path string, reader io.ReadSeeker) { + buf := make([]byte, typesniffer.SniffContentSize) + n, err := util.ReadAtMost(reader, buf) + if err != nil { + log.Error("artifact preview ReadAtMost: %v", err) + WritePreviewRawError(ctx, http.StatusInternalServerError, "failed to read artifact") + return + } + buf = buf[:n] + + if _, err := reader.Seek(0, io.SeekStart); err != nil { + log.Error("artifact preview Seek: %v", err) + WritePreviewRawError(ctx, http.StatusInternalServerError, "failed to read artifact") + return + } + + st := typesniffer.DetectContentType(buf) + if !isPreviewableArtifactType(st) { + WritePreviewRawError(ctx, http.StatusUnsupportedMediaType, "artifact preview is not supported for this file type") + return + } + + // CSP sandbox is applied by httplib.ServeSetHeaders, see HINT: PDF-RENDER-SANDBOX + ctx.ServeContent(reader, context_module.ServeHeaderOptions{ + Filename: path, + ContentType: st.GetMimeType(), + }) +} + +func ArtifactsPreviewView(ctx *context_module.Context) { + artifactName := ctx.PathParam("artifact_name") + + run, artifacts, ok := getCurrentRunAndUploadedArtifacts(ctx, artifactName) + if !ok { + return + } + + paths, err := listPreviewPaths(artifacts) + if err != nil { + ctx.ServerError("listPreviewPaths", err) + return + } + selectedPath := ChoosePreviewPath(paths, GetRequestedPreviewPath(ctx)) + + previewFiles := make([]ArtifactPreviewFile, 0, len(paths)) + for _, path := range paths { + previewFiles = append(previewFiles, ArtifactPreviewFile{ + Path: path, + Selected: path == selectedPath, + }) + } + + runURL := run.Link() + artifactPath := url.PathEscape(artifactName) + previewURL := runURL + "/artifacts/" + artifactPath + "/preview" + + ctx.Data["Title"] = ctx.Tr("preview") + ctx.Data["PageIsActions"] = true + ctx.Data["RunURL"] = runURL + ctx.Data["ArtifactName"] = artifactName + ctx.Data["PreviewURL"] = previewURL + ctx.Data["PreviewRawURL"] = previewURL + "/raw" + ctx.Data["DownloadURL"] = runURL + "/artifacts/" + artifactPath + ctx.Data["SelectedPath"] = selectedPath + ctx.Data["PreviewFiles"] = previewFiles + + ctx.HTML(http.StatusOK, tplArtifactPreviewAction) +} + +// serveArtifactV4PreviewRaw opens the v4 artifact zip once and serves a single file from it, +// avoiding the redundant parse that listPreviewPaths would do for raw fetches. +func serveArtifactV4PreviewRaw(ctx *context_module.Context, artifact *actions_model.ActionArtifact, requested string) { + obj, reader, err := openArtifactV4ZipReader(artifact) + if err != nil { + if !errors.Is(err, zip.ErrFormat) { + log.Error("artifact preview openArtifactV4ZipReader: %v", err) + WritePreviewRawError(ctx, http.StatusInternalServerError, "failed to open artifact") + return + } + fallbackPath := artifactPreviewFallbackPath(artifact) + selectedPath := ChoosePreviewPath([]string{fallbackPath}, requested) + if selectedPath == "" { + WritePreviewRawError(ctx, http.StatusNotFound, "artifact file not found") + return + } + f, err := storage.ActionsArtifacts.Open(artifact.StoragePath) + if err != nil { + log.Error("artifact preview ActionsArtifacts.Open: %v", err) + WritePreviewRawError(ctx, http.StatusInternalServerError, "failed to open artifact") + return + } + defer f.Close() + previewArtifactByReadSeeker(ctx, selectedPath, f) + return + } + defer obj.Close() + + paths, files := listArtifactV4ZipFiles(reader) + selectedPath := ChoosePreviewPath(paths, requested) + if selectedPath == "" { + WritePreviewRawError(ctx, http.StatusNotFound, "artifact file not found") + return + } + zf := files[selectedPath] + r, err := zf.Open() + if err != nil { + log.Error("artifact preview zip.File.Open: %v", err) + WritePreviewRawError(ctx, http.StatusInternalServerError, "failed to open artifact file") + return + } + defer r.Close() + previewArtifactByReader(ctx, selectedPath, r) +} + +func ArtifactsPreviewRawView(ctx *context_module.Context) { + artifactName := ctx.PathParam("artifact_name") + + _, artifacts, ok := getCurrentRunAndUploadedArtifacts(ctx, artifactName) + if !ok { + return + } + requested := GetRequestedPreviewPath(ctx) + + if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) { + serveArtifactV4PreviewRaw(ctx, artifacts[0], requested) + return + } + + paths := listPreviewPathsForLegacyArtifacts(artifacts) + selectedPath := ChoosePreviewPath(paths, requested) + if selectedPath == "" { + WritePreviewRawError(ctx, http.StatusNotFound, "artifact file not found") + return + } + + legacyByPath := make(map[string]*actions_model.ActionArtifact, len(artifacts)) + for _, artifact := range artifacts { + path := artifactPreviewFallbackPath(artifact) + if _, ok := legacyByPath[path]; ok { + continue + } + legacyByPath[path] = artifact + } + + artifact, ok := legacyByPath[selectedPath] + if !ok { + WritePreviewRawError(ctx, http.StatusNotFound, "artifact file not found") + return + } + + f, err := storage.ActionsArtifacts.Open(artifact.StoragePath) + if err != nil { + log.Error("artifact preview ActionsArtifacts.Open: %v", err) + WritePreviewRawError(ctx, http.StatusInternalServerError, "failed to open artifact") + return + } + defer f.Close() + + if artifact.ContentEncodingOrType == actions_model.ContentEncodingV3Gzip { + r, err := gzip.NewReader(f) + if err != nil { + log.Error("artifact preview gzip.NewReader: %v", err) + WritePreviewRawError(ctx, http.StatusInternalServerError, "failed to read artifact") + return + } + defer r.Close() + + previewArtifactByReader(ctx, selectedPath, r) + return + } + + previewArtifactByReadSeeker(ctx, selectedPath, f) +} + +func ArtifactsDeleteView(ctx *context_module.Context) { + run := getCurrentRunByPathParam(ctx) + if ctx.Written() { + return + } + resolvedAttemptID, err := resolveArtifactAttemptIDFromQuery(ctx, run) + if err != nil { + ctx.NotFoundOrServerError("resolveArtifactAttemptIDFromQuery", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) + return + } + artifactName := ctx.PathParam("artifact_name") + if err := actions_model.SetArtifactNeedDeleteByRunAttempt(ctx, run.ID, resolvedAttemptID, artifactName); err != nil { + ctx.ServerError("SetArtifactNeedDeleteByRunAttempt", err) + return + } + ctx.JSON(http.StatusOK, struct{}{}) +} + +func ArtifactsDownloadView(ctx *context_module.Context) { + run := getCurrentRunByPathParam(ctx) + if ctx.Written() { + return + } + resolvedAttemptID, err := resolveArtifactAttemptIDFromQuery(ctx, run) + if err != nil { + ctx.NotFoundOrServerError("resolveArtifactAttemptIDFromQuery", func(err error) bool { + return errors.Is(err, util.ErrNotExist) + }, err) + return + } + artifactName := ctx.PathParam("artifact_name") + artifacts, err := actions_model.GetArtifactsByRunAttemptAndName(ctx, run.ID, resolvedAttemptID, artifactName) + if err != nil { + ctx.ServerError("GetArtifactsByRunAttemptAndName", err) + return + } + if len(artifacts) == 0 { + ctx.HTTPError(http.StatusNotFound, "artifact not found") + return + } + + ctx.Resp.Header().Set("Content-Disposition", httplib.EncodeContentDispositionAttachment(artifactName+".zip")) + if len(artifacts) == 1 && actions.IsArtifactV4(artifacts[0]) { + err := actions.DownloadArtifactV4(ctx.Base, artifacts[0]) + if err != nil { + ctx.ServerError("DownloadArtifactV4", err) + return + } + return + } + + // Artifacts using the v1-v3 backend are stored as multiple individual files per artifact on the backend + // Those need to be zipped for download + zipWriter := zip.NewWriter(ctx.Resp) + defer zipWriter.Close() + + writeArtifactToZip := func(art *actions_model.ActionArtifact) error { + f, err := storage.ActionsArtifacts.Open(art.StoragePath) + if err != nil { + return fmt.Errorf("ActionsArtifacts.Open: %w", err) + } + defer f.Close() + + var r io.ReadCloser = f + if art.ContentEncodingOrType == actions_model.ContentEncodingV3Gzip { + r, err = gzip.NewReader(f) + if err != nil { + return fmt.Errorf("gzip.NewReader: %w", err) + } + } + defer r.Close() + + w, err := zipWriter.Create(art.ArtifactPath) + if err != nil { + return fmt.Errorf("zipWriter.Create: %w", err) + } + _, err = io.Copy(w, r) + if err != nil { + return fmt.Errorf("io.Copy: %w", err) + } + return nil + } + + for _, art := range artifacts { + err := writeArtifactToZip(art) + if err != nil { + ctx.ServerError("writeArtifactToZip", err) + return + } + } +} diff --git a/templates/shared/actions/artifact_preview_content.tmpl b/templates/shared/actions/artifact_preview_content.tmpl index 41c85faa2d..6d33a75ab5 100644 --- a/templates/shared/actions/artifact_preview_content.tmpl +++ b/templates/shared/actions/artifact_preview_content.tmpl @@ -49,8 +49,9 @@
{{if .SelectedPath}} - {{/* iframe sandbox="" matches the response CSP `sandbox` directive: opaque origin, no scripts, no forms, no popups. */}} - + {{/* sandbox="allow-scripts": scripts execute but in a null/opaque origin (no allow-same-origin), so they cannot + access Gitea cookies or the parent frame. The response CSP reinforces this and blocks connect-src. */}} + {{else}}
{{ctx.Locale.Tr "none"}}
{{end}} diff --git a/tests/integration/actions_artifact_preview_test.go b/tests/integration/actions_artifact_preview_test.go index 4b36ea22e4..4fd731f171 100644 --- a/tests/integration/actions_artifact_preview_test.go +++ b/tests/integration/actions_artifact_preview_test.go @@ -51,7 +51,7 @@ func TestActionsArtifactPreviewSingleFile(t *testing.T) { resp := session.MakeRequest(t, req, http.StatusOK) assert.Contains(t, resp.Body.String(), "abc.txt") assert.Contains(t, resp.Body.String(), "/preview/raw/abc.txt") - assert.Contains(t, resp.Body.String(), `sandbox=""`) + assert.Contains(t, resp.Body.String(), `sandbox="allow-scripts"`) assert.Contains(t, resp.Body.String(), `referrerpolicy="no-referrer"`) req = NewRequestf(t, "GET", "/%s/actions/runs/791/artifacts/artifact-download/preview/raw", repo.FullName())