Files
warmbly/internal/api/handler/dashboard_image.go
T
2026-10-06 07:28:25 +00:00

187 lines
5.4 KiB
Go

package handler
import (
"bytes"
"context"
"encoding/xml"
"errors"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/warmbly/warmbly/internal/errx"
"github.com/warmbly/warmbly/internal/infrastructure/storage"
"github.com/warmbly/warmbly/internal/pkg/safehttp"
)
const dashboardImageMaxBytes = 5 << 20
var (
dashboardImageHTTP = safehttp.PublicClient(10 * time.Second)
dashboardImageSlots = make(chan struct{}, 8)
errDashboardImage = errors.New("image preview unavailable; use a public image of 5 MB or smaller")
)
func (h *Handler) DashboardImage(c *gin.Context) {
h.dashboardImage(c, false)
}
// PublicDashboardImage serves only owned public blobs and Slack sign-in avatars.
func (h *Handler) PublicDashboardImage(c *gin.Context) {
h.dashboardImage(c, true)
}
func (h *Handler) dashboardImage(c *gin.Context, publicOnly bool) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
var body struct {
URL string `json:"url" binding:"required,max=8192"`
}
if err := c.ShouldBindJSON(&body); err != nil {
errx.Handle(c, errx.InvalidBody(err))
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
select {
case dashboardImageSlots <- struct{}{}:
defer func() { <-dashboardImageSlots }()
case <-ctx.Done():
errx.JSON(c, errx.New(errx.ServiceUnavailable, "image preview is busy; try again"))
return
}
data, contentType, err := loadDashboardImage(ctx, body.URL, h.Storage, dashboardImageHTTP, publicOnly)
if err != nil {
errx.JSON(c, errx.New(errx.BadRequest, errDashboardImage.Error()))
return
}
c.Header("Cache-Control", "no-store")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Security-Policy", "default-src 'none'; script-src 'none'; sandbox; frame-ancestors 'none'")
c.Data(http.StatusOK, contentType, data)
}
func loadDashboardImage(ctx context.Context, raw string, store storage.Store, client *http.Client, publicOnly bool) ([]byte, string, error) {
var reader io.ReadCloser
if pu, ok := store.(storage.PublicURLer); ok && pu.PublicURL("") != "" && strings.HasPrefix(raw, pu.PublicURL("")) {
key := strings.TrimPrefix(raw, pu.PublicURL(""))
if !isPublicKey(key) || pu.PublicURL(key) != raw {
return nil, "", errDashboardImage
}
var err error
reader, err = store.Get(ctx, key)
if err != nil {
return nil, "", errDashboardImage
}
} else {
u, err := url.Parse(raw)
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.Opaque != "" {
return nil, "", errDashboardImage
}
if publicOnly && !publicAvatarURL(u) {
return nil, "", errDashboardImage
}
if u.Port() != "" && u.Port() != "443" && u.Port() != "8443" {
return nil, "", errDashboardImage
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, "", errDashboardImage
}
req.Header.Set("Accept", "image/*")
if publicOnly {
publicClient := *client
publicClient.CheckRedirect = func(next *http.Request, via []*http.Request) error {
if !publicAvatarURL(next.URL) || len(via) >= 5 {
return errDashboardImage
}
if client.CheckRedirect != nil {
return client.CheckRedirect(next, via)
}
return nil
}
client = &publicClient
}
res, err := client.Do(req)
if err != nil {
return nil, "", errDashboardImage
}
if res.StatusCode != http.StatusOK || res.ContentLength > dashboardImageMaxBytes {
_ = res.Body.Close()
return nil, "", errDashboardImage
}
reader = res.Body
}
defer reader.Close()
data, err := io.ReadAll(io.LimitReader(reader, dashboardImageMaxBytes+1))
if err != nil || len(data) == 0 || len(data) > dashboardImageMaxBytes {
return nil, "", errDashboardImage
}
contentType := dashboardImageType(data)
if contentType == "" {
return nil, "", errDashboardImage
}
return data, contentType, nil
}
func publicAvatarURL(u *url.URL) bool {
return u.Scheme == "https" && u.User == nil && (u.Host == "avatars.slack-edge.com" || (u.Host == "secure.gravatar.com" && strings.HasPrefix(u.Path, "/avatar/")))
}
func dashboardImageType(data []byte) string {
contentType := http.DetectContentType(data)
switch contentType {
case "image/png", "image/jpeg", "image/gif", "image/webp", "image/x-icon", "image/bmp":
return contentType
}
if len(data) >= 16 && string(data[4:8]) == "ftyp" && (string(data[8:12]) == "avif" || string(data[8:12]) == "avis") {
return "image/avif"
}
decoder := xml.NewDecoder(bytes.NewReader(data))
root, depth := false, 0
for {
token, err := decoder.Token()
if err == io.EOF {
if root && depth == 0 {
return "image/svg+xml"
}
return ""
}
if err != nil {
return ""
}
switch element := token.(type) {
case xml.StartElement:
if element.Name.Local == "script" || element.Name.Local == "foreignObject" {
return ""
}
for _, attr := range element.Attr {
if strings.HasPrefix(strings.ToLower(attr.Name.Local), "on") || (attr.Name.Local == "href" && strings.HasPrefix(strings.ToLower(strings.TrimSpace(attr.Value)), "javascript:")) {
return ""
}
}
if depth == 0 {
if root || element.Name.Local != "svg" || element.Name.Space != "http://www.w3.org/2000/svg" {
return ""
}
root = true
}
depth++
if depth > 64 {
return ""
}
case xml.EndElement:
depth--
case xml.CharData:
if depth == 0 && len(bytes.TrimSpace(element)) != 0 {
return ""
}
case xml.Directive:
return ""
}
}
}