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: ` + + + +| Directory/File | +Lines | +Coverage | +
|---|---|---|
| src/ | ||
| main.go | 120 / 132 | 90.9 % |
| util.go | 45 / 60 | 75.0 % |
| legacy.go | 12 / 38 | 31.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: ` + +| 1 | func main() { |
| 2 | fmt.Println("hello") |
| 3 | } |
| 10 | unusedFunc() |
%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 @@