Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion internal/bootstrap/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package bootstrap
import (
"log/slog"
"net/http"
"path"
"strconv"
"strings"
"time"
Expand All @@ -17,6 +18,7 @@ import (
"agent-desk/internal/pkg/i18nx"
"agent-desk/internal/pkg/tracex"
"agent-desk/internal/services"
"agent-desk/internal/services/storage"
webspa "agent-desk/web"

"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -44,11 +46,29 @@ func NewServer() (*gin.Engine, error) {

handleSpa(app)

app.StaticFS(cfg.Storage.Local.BaseURL, ginx.StaticFiles(cfg.Storage.Local.Root))
storageGroup := app.Group(cfg.Storage.Local.BaseURL, assetResponseHeaders())
storageGroup.StaticFS("", ginx.StaticFiles(cfg.Storage.Local.Root))

return app, nil
}

// assetResponseHeaders guards the locally stored assets.
//
// Those files are user-supplied bytes served from this application's own origin,
// so a response a browser renders inline is same-origin content. nosniff stops a
// browser reinterpreting the payload, and forcing a download for anything that is
// not previewable media means a document type that slipped in before this policy
// existed still cannot run as a page.
func assetResponseHeaders() gin.HandlerFunc {
return func(ctx *gin.Context) {
ctx.Header("X-Content-Type-Options", "nosniff")
if !storage.IsPreviewableExtension(path.Ext(ctx.Request.URL.Path)) {
ctx.Header("Content-Disposition", "attachment")
}
ctx.Next()
}
}

func corsMiddleware() gin.HandlerFunc {
allowedOrigins := config.Current().Server.CORS.AllowedOrigins
allowHeaders := "Origin, Content-Type, Accept, Authorization, X-Requested-With, X-Guest-Id, X-Channel-Id, X-External-Id, X-External-Name, X-Customer-Session-Token, X-Customer-Session-Expires-At"
Expand Down
58 changes: 58 additions & 0 deletions internal/bootstrap/server_route_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,64 @@ func TestNewServerSeparatesAPIStaticAndSPA(t *testing.T) {
}
}

func TestNewServerHardensStoredAssetResponses(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"screenshot.png", "archive.zip", "legacy-page.html"} {
if err := os.WriteFile(filepath.Join(root, name), []byte("payload"), 0o644); err != nil {
t.Fatalf("WriteFile(%s) error = %v", name, err)
}
}

config.SetCurrent(&config.Config{
Storage: config.StorageConfig{
Local: config.LocalStorageConfig{
Root: root,
BaseURL: "/storage",
},
},
})

app, err := NewServer()
if err != nil {
t.Fatalf("NewServer() error = %v", err)
}

tests := []struct {
path string
wantStatus int
contentType string
wantAttachment bool
}{
{path: "/storage/screenshot.png", wantStatus: http.StatusOK, contentType: "image/png"},
{path: "/storage/archive.zip", wantStatus: http.StatusOK, wantAttachment: true},
// A file planted before the upload policy existed must still not render.
{path: "/storage/legacy-page.html", wantStatus: http.StatusOK, wantAttachment: true},
{path: "/storage/missing.png", wantStatus: http.StatusNotFound},
}

for _, tt := range tests {
rec := httptest.NewRecorder()
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil))

if rec.Code != tt.wantStatus {
t.Fatalf("%s status=%d want %d", tt.path, rec.Code, tt.wantStatus)
}
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("%s X-Content-Type-Options=%q want nosniff", tt.path, got)
}
if tt.contentType != "" && !strings.Contains(rec.Header().Get("Content-Type"), tt.contentType) {
t.Fatalf("%s Content-Type=%q want %q", tt.path, rec.Header().Get("Content-Type"), tt.contentType)
}
got := rec.Header().Get("Content-Disposition")
if tt.wantAttachment && got != "attachment" {
t.Fatalf("%s Content-Disposition=%q want attachment", tt.path, got)
}
if !tt.wantAttachment && got != "" {
t.Fatalf("%s Content-Disposition=%q want empty so the asset renders inline", tt.path, got)
}
}
}

func TestNewServerAllowsConfiguredCORSOrigin(t *testing.T) {
config.SetCurrent(&config.Config{
Server: config.ServerConfig{
Expand Down
1 change: 1 addition & 0 deletions internal/pkg/i18nx/locales/en-US.yml
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ error.e0344: "Invalid attachment message payload format."
error.e0345: "Attachment message is missing assetId."
error.e0346: "Attachment message is missing payload."
error.e0347: "Default team queue mode requires at least one agent team."
error.e0348: "This file type cannot be uploaded for security reasons."
error.profile.nicknameRequired: "Enter a nickname."
error.profile.nicknameTooLong: "Nickname cannot exceed 100 characters."
error.profile.avatarTooLong: "Avatar link cannot exceed 255 characters."
Expand Down
1 change: 1 addition & 0 deletions internal/pkg/i18nx/locales/zh-CN.yml
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ error.e0344: "附件消息 payload 格式错误"
error.e0345: "附件消息缺少 assetId"
error.e0346: "附件消息缺少 payload"
error.e0347: "默认客服组待接入池模式必须至少选择一个客服组"
error.e0348: "出于安全考虑,此文件类型不支持上传"
error.profile.nicknameRequired: "请输入昵称"
error.profile.nicknameTooLong: "昵称不能超过 100 个字符"
error.profile.avatarTooLong: "头像链接不能超过 255 个字符"
Expand Down
25 changes: 20 additions & 5 deletions internal/services/asset_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,16 @@ func (s *assetService) OpenReader(asset *models.Asset) (io.ReadCloser, error) {
}

func (s *assetService) UploadBytes(data []byte, prefix, filename string, principal *dto.AuthPrincipal) (*models.Asset, error) {
src := bytes.NewReader(data)
return s.Upload(src, storage.UploadInfo{
filename = storage.SanitizeFilename(filename)
mimeType, err := storage.ValidateUpload(filename, "", http.DetectContentType(data))
if err != nil {
return nil, err
}
return s.Upload(bytes.NewReader(data), storage.UploadInfo{
Prefix: prefix,
Filename: filename,
FileSize: int64(len(data)),
MimeType: http.DetectContentType(data),
MimeType: mimeType,
Principal: principal,
})
}
Expand All @@ -87,11 +91,22 @@ func (s *assetService) UploadFile(file *multipart.FileHeader, prefix string, pri
}
defer func() { _ = src.Close() }()

sniffed, err := storage.SniffContentType(src)
if err != nil {
return nil, err
}

filename := storage.SanitizeFilename(file.Filename)
mimeType, err := storage.ValidateUpload(filename, file.Header.Get("Content-Type"), sniffed)
if err != nil {
return nil, err
}

return s.Upload(src, storage.UploadInfo{
Prefix: prefix,
Filename: file.Filename,
Filename: filename,
FileSize: file.Size,
MimeType: file.Header.Get("Content-Type"),
MimeType: mimeType,
Principal: principal,
})
}
Expand Down
218 changes: 218 additions & 0 deletions internal/services/storage/safety.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package storage

import (
"io"
"mime"
"net/http"
"path"
"strings"
"unicode/utf8"

"agent-desk/internal/pkg/errorsx"
)

const (
// sniffLimit is the number of leading bytes net/http.DetectContentType inspects.
sniffLimit = 512
// maxFilenameLength keeps a stored filename inside the column that holds it.
maxFilenameLength = 255
)

// previewableExtensions are the only stored file types a browser may render
// inline. Every other extension is forced to download.
//
// Assets are served from this application's own origin, so an inline response is
// same-origin content: a browser that navigates to it runs whatever the bytes
// ask for, in the same security context as the dashboard and the public support
// widget. Forcing a download for anything we have not vetted keeps that true
// only for media, which cannot touch the DOM.
var previewableExtensions = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
".bmp": true, ".ico": true, ".avif": true, ".tif": true, ".tiff": true,
".heic": true, ".heif": true,
".mp4": true, ".m4v": true, ".webm": true, ".mov": true, ".ogv": true,
".mp3": true, ".m4a": true, ".wav": true, ".ogg": true, ".oga": true,
".aac": true, ".flac": true,
".pdf": true,
}

// blockedExtensions are rejected at upload time. Each one names a document a
// browser executes or lays out instead of displaying, so accepting it would let
// an unauthenticated visitor plant a script that runs against this origin the
// moment a staff member opens the attachment link.
var blockedExtensions = map[string]bool{
".html": true, ".htm": true, ".shtml": true, ".xhtml": true, ".xht": true,
".svg": true, ".svgz": true,
".xml": true, ".xsl": true, ".xslt": true, ".xsd": true, ".wsdl": true,
".js": true, ".mjs": true, ".cjs": true,
".swf": true,
".php": true, ".phtml": true, ".php3": true, ".php4": true, ".php5": true,
".asp": true, ".aspx": true, ".jsp": true, ".jspx": true, ".cfm": true,
".hta": true, ".htc": true, ".htaccess": true,
}

// blockedMediaTypes are the payloads a browser renders as an active document.
// They are rejected regardless of the extension the client chose, so renaming a
// page to .png does not smuggle it past the extension check.
var blockedMediaTypes = map[string]bool{
"text/html": true,
"application/xhtml+xml": true,
"image/svg+xml": true,
"text/xml": true,
"application/xml": true,
"application/xslt+xml": true,
"text/javascript": true,
"application/javascript": true,
"application/x-javascript": true,
}

// blockedUploadI18nKey is returned to the uploader whenever the file-safety
// policy rejects a payload. It deliberately does not name the offending type.
const blockedUploadI18nKey = "error.e0348"

// mediaTypeExtensions maps a MIME type to the extension it should be stored
// under. mime.ExtensionsByType cannot be used here: on Windows it consults the
// registry, so the same type resolves to a different extension than in
// production, and it happily hands back an extension that describes an active
// document. Types that must never become a servable document are absent, which
// leaves the caller with the inert .bin fallback.
var mediaTypeExtensions = map[string]string{
// images
"image/jpeg": ".jpg", "image/jfif": ".jpg", "image/pjpeg": ".jpg",
"image/png": ".png", "image/gif": ".gif", "image/webp": ".webp",
"image/bmp": ".bmp", "image/tiff": ".tiff", "image/avif": ".avif",
"image/heic": ".heic", "image/heif": ".heif", "image/x-icon": ".ico",
"image/vnd.microsoft.icon": ".ico",
// documents
"application/pdf": ".pdf", "text/plain": ".txt", "text/csv": ".csv",
"text/markdown": ".md", "application/json": ".json", "application/rtf": ".rtf",
"application/msword": ".doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/vnd.ms-excel": ".xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
"application/vnd.ms-powerpoint": ".ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
"application/vnd.oasis.opendocument.text": ".odt",
"application/vnd.oasis.opendocument.spreadsheet": ".ods",
"application/vnd.oasis.opendocument.presentation": ".odp",
// archives
"application/zip": ".zip", "application/x-zip-compressed": ".zip",
"application/x-rar-compressed": ".rar", "application/vnd.rar": ".rar",
"application/x-7z-compressed": ".7z", "application/x-tar": ".tar",
"application/gzip": ".gz", "application/x-bzip2": ".bz2", "application/x-xz": ".xz",
// audio
"audio/mpeg": ".mp3", "audio/mp4": ".m4a", "audio/x-m4a": ".m4a",
"audio/wav": ".wav", "audio/x-wav": ".wav", "audio/ogg": ".ogg",
"audio/aac": ".aac", "audio/flac": ".flac", "audio/webm": ".webm",
// video
"video/mp4": ".mp4", "video/webm": ".webm", "video/quicktime": ".mov",
"video/x-msvideo": ".avi", "video/x-matroska": ".mkv", "video/ogg": ".ogv",
"video/mpeg": ".mpeg",
}

// safeExtensionForMediaType returns the extension a payload of this type should
// be stored under, or "" when the type is not one this origin is willing to
// serve as a document.
func safeExtensionForMediaType(mediaType string) string {
return mediaTypeExtensions[strings.ToLower(strings.TrimSpace(mediaType))]
}

// IsPreviewableExtension reports whether a stored file may be rendered inline.
func IsPreviewableExtension(ext string) bool {
return previewableExtensions[normalizeExt(ext)]
}

// IsBlockedExtension reports whether an extension names a browser-active document.
func IsBlockedExtension(ext string) bool {
return blockedExtensions[normalizeExt(ext)]
}

// IsBlockedMediaType reports whether a MIME type describes a browser-active document.
func IsBlockedMediaType(mediaType string) bool {
parsed, _, err := mime.ParseMediaType(strings.TrimSpace(mediaType))
if err != nil || parsed == "" {
return false
}
return blockedMediaTypes[strings.ToLower(parsed)]
}

// SanitizeFilename reduces a client-supplied name to a bare basename. Uploads
// arrive from browsers, mobile SDKs and channel webhooks, all of which are free
// to send a full path, control characters or nothing at all; the result is stored
// on the asset and echoed back into message payloads and download links.
func SanitizeFilename(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
name = path.Base(strings.ReplaceAll(name, "\\", "/"))
name = strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, name)
name = strings.TrimSpace(strings.TrimRight(name, ". "))
if len(name) > maxFilenameLength {
ext := path.Ext(name)
if len(ext) > maxFilenameLength {
ext = ""
}
stem := strings.TrimSuffix(name, ext)
// Cut on a rune boundary: a partial multibyte character would not survive
// the round trip through a utf8mb4 column.
limit := maxFilenameLength - len(ext)
for limit > 0 && !utf8.RuneStart(stem[limit]) {
limit--
}
name = stem[:limit] + ext
}
return name
}

// SniffContentType reports what the leading bytes of a seekable payload actually
// are, then rewinds so the caller can still stream the whole file to storage.
// The Content-Type a client declares is a claim, not evidence.
func SniffContentType(src io.ReadSeeker) (string, error) {
head := make([]byte, sniffLimit)
read, err := io.ReadFull(src, head)
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
return "", err
}
if _, err := src.Seek(0, io.SeekStart); err != nil {
return "", err
}
return http.DetectContentType(head[:read]), nil
}

// ValidateUpload applies the file-safety policy to an upload and returns the MIME
// type that should be recorded on the asset.
//
// sniffed comes from SniffContentType and wins whenever it identified something
// specific; declared is what the client claimed and is only trusted for the
// formats net/http cannot recognise, such as HEIC and most Office documents.
func ValidateUpload(filename, declared, sniffed string) (string, error) {
if IsBlockedExtension(path.Ext(filename)) {
return "", errorsx.InvalidParamI18n(blockedUploadI18nKey)
}
if IsBlockedMediaType(sniffed) || IsBlockedMediaType(declared) {
return "", errorsx.InvalidParamI18n(blockedUploadI18nKey)
}

mediaType, _, _ := mime.ParseMediaType(sniffed)
if mediaType != "" && mediaType != "application/octet-stream" && !strings.HasPrefix(mediaType, "text/") {
return sniffed, nil
}
if declared != "" {
return declared, nil
}
return sniffed, nil
}

func normalizeExt(ext string) string {
ext = strings.ToLower(strings.TrimSpace(ext))
if ext != "" && !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
return ext
}
Loading
Loading