mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-12 16:02:44 +00:00
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
187 lines
5.4 KiB
Go
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 ""
|
|
}
|
|
}
|
|
}
|