mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 08:02:04 +00:00
98 lines
3.6 KiB
Go
98 lines
3.6 KiB
Go
package handler
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/warmbly/warmbly/internal/infrastructure/storage"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
// ServePublicObject streams a publicly-readable blob (avatar, org logo, form
|
|
// asset, email-body image) from the active storage backend. It exists for the
|
|
// filesystem backend, which has no authority to serve objects itself; the S3
|
|
// backend returns object-storage URLs from PutPublic and never routes through
|
|
// here. Only the fixed public key prefixes are served so this can't be used to
|
|
// read arbitrary stored objects.
|
|
func (h *Handler) ServePublicObject(c *gin.Context) {
|
|
key := strings.TrimPrefix(c.Param("key"), "/")
|
|
if key == "" || !isPublicKey(key) {
|
|
c.Status(http.StatusNotFound)
|
|
return
|
|
}
|
|
if h.Storage == nil {
|
|
c.Status(http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
body, err := h.Storage.Get(c.Request.Context(), key)
|
|
if err != nil {
|
|
if errors.Is(err, storage.ErrNotFound) {
|
|
c.Status(http.StatusNotFound)
|
|
return
|
|
}
|
|
c.Status(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer body.Close()
|
|
|
|
// The Content-Type is derived from the key's extension, and every upload
|
|
// handler forces that extension from a server-side image allowlist. An
|
|
// extension outside the allowlist therefore means the key did not come from
|
|
// an upload handler, and the only safe thing to serve is a download.
|
|
ct := mime.TypeByExtension(filepath.Ext(key))
|
|
if ct != "" && servableInline[strings.ToLower(filepath.Ext(key))] {
|
|
c.Header("Content-Type", ct)
|
|
c.Header("Content-Disposition", "inline")
|
|
} else {
|
|
c.Header("Content-Type", "application/octet-stream")
|
|
c.Header("Content-Disposition", "attachment")
|
|
}
|
|
// These are user uploads served from our own origin, so the browser must
|
|
// not be free to decide they are something executable.
|
|
c.Header("X-Content-Type-Options", "nosniff")
|
|
// Belt and braces behind the extension allowlist: even if something
|
|
// script-capable reached a public key, this origin holds no session cookie
|
|
// and the sandbox denies it an origin to act in.
|
|
c.Header("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; sandbox; frame-ancestors 'none'")
|
|
// These objects exist to be loaded from somewhere else: an email image is
|
|
// fetched by the recipient's mail client, an avatar by a page on another
|
|
// host. The API-wide same-site policy would block exactly that, so this
|
|
// route opts out. Safe because the objects are public by definition and the
|
|
// origin carries no cookie.
|
|
c.Header("Cross-Origin-Resource-Policy", "cross-origin")
|
|
// Keys are content-addressed (they carry an epoch suffix), so they're safe
|
|
// to cache immutably.
|
|
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
|
c.Status(http.StatusOK)
|
|
_, _ = io.Copy(c.Writer, body)
|
|
}
|
|
|
|
// servableInline is the set of extensions the upload handlers can produce. A
|
|
// public object outside it is handed over as a download rather than rendered,
|
|
// so a key that somehow carries .svg or .html cannot become script on this
|
|
// origin.
|
|
var servableInline = map[string]bool{
|
|
".png": true,
|
|
".jpg": true,
|
|
".jpeg": true,
|
|
".gif": true,
|
|
".webp": true,
|
|
}
|
|
|
|
// isPublicKey guards the /public route to the key prefixes PutPublic writes, so
|
|
// it can't be turned into a reader for arbitrary blob keys.
|
|
func isPublicKey(key string) bool {
|
|
if strings.Contains(key, "..") {
|
|
return false
|
|
}
|
|
return strings.HasPrefix(key, "avatars/") || strings.HasPrefix(key, "oauth-app-logos/") ||
|
|
strings.HasPrefix(key, "form-assets/") || strings.HasPrefix(key, models.EmailImageKeyPrefix)
|
|
}
|