Add SVG support for custom organization logos (#44748)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Follow-up to #44390 (BE/FE) and #44550 (GitOps).
Parent story #39016.

## Summary

Accepts `.svg` for organization logo uploads in addition to
PNG/JPEG/WebP, with strict server-side validation since SVGs can carry
scripts.

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [x] Added/updated automated tests

- [x] QA'd all new/changed functionality manually



https://github.com/user-attachments/assets/318d320e-ff78-41fe-ad3a-55d6dace8dc0



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Organization logos now accept SVG in addition to PNG, JPEG, and WebP.
  * Stored SVG logos are re-validated when served.

* **Security**
* Server applies strict SVG sanitization to block scripts, unsafe
elements, event handlers, and unsafe URL schemes.
* SVG logo responses include headers to prevent content-type sniffing
and restrict execution.

* **Tests**
* Added tests covering SVG detection, validation, allowed/rejected
cases, and serving behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Nico
2026-05-06 15:20:53 +02:00
committed by GitHub
parent 4fa55e5e55
commit fd3ec5a9aa
7 changed files with 379 additions and 17 deletions
+1
View File
@@ -0,0 +1 @@
* Added SVG support for custom organization logos, with strict server-side sanitization to reject scripts and other unsafe SVG content.
+30 -4
View File
@@ -1,8 +1,8 @@
export const ORG_LOGO_ACCEPT = ".png,.jpg,.jpeg,.webp";
export const ORG_LOGO_ACCEPT = ".png,.jpg,.jpeg,.webp,.svg";
export const ORG_LOGO_MAX_SIZE_BYTES = 100 * 1024; // 100 KB
export const ORG_LOGO_HELP_TEXT =
"Personalize Fleet with your brand. For best results, use a square image at least 150px wide.";
export const ORG_LOGO_ALLOWED_TYPES = ["png", "jpeg", "webp"] as const;
export const ORG_LOGO_ALLOWED_TYPES = ["png", "jpeg", "webp", "svg"] as const;
export type ImageFileType = typeof ORG_LOGO_ALLOWED_TYPES[number];
@@ -11,6 +11,11 @@ const ORG_LOGO_ALLOWED_TYPES_LABEL = `${upperAllowedTypes
.slice(0, -1)
.join(", ")}, or ${upperAllowedTypes[upperAllowedTypes.length - 1]}`;
// Larger than any non-SVG magic-byte prefix so a single read covers all
// formats. SVG detection scans the head for "<svg" anywhere, allowing
// for an XML declaration / comments / DOCTYPE before the root tag.
const SNIFF_BYTES = 1024;
export interface IOrgLogoValidationResult {
valid: boolean;
error?: string;
@@ -46,10 +51,31 @@ const detectImageType = (bytes: Uint8Array): ImageFileType | null => {
) {
return "webp";
}
// SVG is text. Require <svg as the first start tag — accepting any
// text containing "<svg" anywhere would let an HTML file with an
// inline <svg> sail past the FE check and only get rejected after
// the upload round-trips. Strip BOM, leading whitespace, an optional
// <?xml ...?> declaration, and any leading comments / DOCTYPE / PIs,
// then look for <svg at the head.
let text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
text = text.replace(/^\s+/, "");
text = text.replace(/^<\?xml\b[^?]*\?>\s*/, "");
// Comments / DOCTYPE / other PIs may all stack before the root.
let prev: string;
do {
prev = text;
text = text.replace(/^<!--[\s\S]*?-->\s*/, "");
text = text.replace(/^<!DOCTYPE[^>]*>\s*/i, "");
text = text.replace(/^<\?[\s\S]*?\?>\s*/, "");
} while (text !== prev);
if (/^<svg\b/i.test(text)) {
return "svg";
}
return null;
};
// validateOrgLogoFile sniffs the first 12 bytes of a File to verify
// validateOrgLogoFile sniffs the leading bytes of a File to verify
// it's one of the allowed image formats — the browser-reported
// file.type is based on extension, not content, so we can't trust it
// (e.g. a WebP saved with a `.png` extension).
@@ -59,7 +85,7 @@ export const validateOrgLogoFile = async (
if (file.size > ORG_LOGO_MAX_SIZE_BYTES) {
return { valid: false, error: "Logo must be 100 KB or less." };
}
const headerBuf = await file.slice(0, 12).arrayBuffer();
const headerBuf = await file.slice(0, SNIFF_BYTES).arrayBuffer();
const detected = detectImageType(new Uint8Array(headerBuf));
if (!detected || !ORG_LOGO_ALLOWED_TYPES.includes(detected)) {
return {
+138 -9
View File
@@ -3,10 +3,15 @@ package fleet
import (
"bytes"
"context"
"encoding/xml"
"errors"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"io"
"net/url"
"strings"
_ "golang.org/x/image/webp"
)
@@ -61,8 +66,24 @@ func hasWebPMagic(b []byte) bool {
return len(b) >= 12 && bytes.Equal(b[0:4], []byte("RIFF")) && bytes.Equal(b[8:12], []byte("WEBP"))
}
// looksLikeSVG only decides which validator to run; validateSVG is what
// actually rejects unsafe content.
func looksLikeSVG(b []byte) bool {
// Editors may prepend a UTF-8 BOM (byte-order mark) or whitespace.
if bytes.HasPrefix(b, []byte{0xEF, 0xBB, 0xBF}) {
b = b[3:]
}
b = bytes.TrimLeft(b, " \t\r\n")
// 512B keeps routing cheap; the root <svg> is always near the top.
const window = 512
if len(b) > window {
b = b[:window]
}
return bytes.Contains(bytes.ToLower(b), []byte("<svg"))
}
// ContentTypeForOrgLogo returns the HTTP Content-Type for the accepted org
// logo formats (PNG, JPEG, WebP) based on the leading bytes, or "" for
// logo formats (PNG, JPEG, WebP, SVG) based on the leading bytes, or "" for
// anything else.
func ContentTypeForOrgLogo(b []byte) string {
switch {
@@ -72,6 +93,8 @@ func ContentTypeForOrgLogo(b []byte) string {
return "image/jpeg"
case hasWebPMagic(b):
return "image/webp"
case looksLikeSVG(b):
return "image/svg+xml"
}
return ""
}
@@ -84,16 +107,122 @@ func ValidateOrgLogoBytes(b []byte) error {
if int64(len(b)) > OrgLogoMaxFileSize {
return &BadRequestError{Message: "logo must be 100KB or less"}
}
_, format, err := image.DecodeConfig(bytes.NewReader(b))
switch ContentTypeForOrgLogo(b) {
case "image/png", "image/jpeg", "image/webp":
_, format, err := image.DecodeConfig(bytes.NewReader(b))
if err != nil {
return &BadRequestError{
Message: "logo must be a valid PNG, JPEG, WebP, or SVG image",
InternalErr: err,
}
}
switch format {
case "png", "jpeg", "webp":
return nil
}
case "image/svg+xml":
return validateSVG(b)
}
return &BadRequestError{Message: "logo must be a PNG, JPEG, WebP, or SVG file"}
}
func isSafeSVGURL(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" || strings.HasPrefix(raw, "#") {
return true
}
u, err := url.Parse(raw)
if err != nil {
return &BadRequestError{
Message: "logo must be a valid PNG, JPEG, or WebP image",
InternalErr: err,
return false
}
if u.Scheme == "" {
// Bare relative path or fragment is fine; protocol-relative
// "//host/x" parses to empty Scheme + non-empty Host and a
// browser resolves it against the page's scheme — reject.
return u.Host == ""
}
s := strings.ToLower(u.Scheme)
return s == "http" || s == "https"
}
// Elements that can run scripts or load foreign content. <img>-rendered
// SVGs are script-sandboxed, but pasting the URL loads it as a document
// — so reject structurally instead of trusting the renderer. SMIL
// animation tags are blocked because they can mutate href/xlink:href at
// runtime and bypass the static href allowlist.
var disallowedSVGElements = map[string]struct{}{
"script": {},
"foreignobject": {},
"iframe": {},
"object": {},
"embed": {},
"set": {},
"animate": {},
"animatetransform": {},
"animatemotion": {},
}
// validateSVG rejects unsafe SVG content. Leaving decoder.Entity nil and
// rejecting DOCTYPE neutralizes XXE (external entities reading local
// files) and billion-laughs (DoS via recursive entity expansion).
func validateSVG(b []byte) error {
decoder := xml.NewDecoder(bytes.NewReader(b))
decoder.Strict = true
sawRoot := false
for {
tok, err := decoder.Token()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return &BadRequestError{
Message: "logo is not valid SVG",
InternalErr: err,
}
}
switch t := tok.(type) {
case xml.StartElement:
name := strings.ToLower(t.Name.Local)
if !sawRoot {
if name != "svg" {
return &BadRequestError{Message: "logo SVG must have <svg> as the root element"}
}
sawRoot = true
}
if _, bad := disallowedSVGElements[name]; bad {
return &BadRequestError{Message: fmt.Sprintf("SVG element <%s> is not allowed", name)}
}
for _, attr := range t.Attr {
attrName := strings.ToLower(attr.Name.Local)
// on* (onclick, onload, …) is SVG's main XSS vector.
if strings.HasPrefix(attrName, "on") {
return &BadRequestError{Message: "SVG event-handler attributes are not allowed"}
}
// Name.Local matches both href and xlink:href. xml:base
// is here too: a hostile base ("javascript:") would
// re-anchor every relative href and bypass this check.
if attrName == "href" || attrName == "src" || attrName == "base" {
if !isSafeSVGURL(attr.Value) {
return &BadRequestError{Message: "SVG href/src/xml:base must be a fragment, relative path, or http(s):// URL"}
}
}
}
case xml.Directive:
// DOCTYPE / ENTITY (XXE, billion-laughs vectors).
return &BadRequestError{Message: "SVG DTD/DOCTYPE declarations are not allowed"}
case xml.ProcInst:
// The leading `<?xml ...?>` declaration is reported as a
// ProcInst with Target=="xml". Anything else (most notably
// `<?xml-stylesheet href="..."?>`) pulls external resources
// when the SVG is opened as a document — block it.
if t.Target != "xml" {
return &BadRequestError{Message: fmt.Sprintf("SVG processing instruction <?%s ...?> is not allowed", t.Target)}
}
}
}
switch format {
case "png", "jpeg", "webp":
return nil
if !sawRoot {
return &BadRequestError{Message: "logo SVG missing root <svg> element"}
}
return &BadRequestError{Message: "logo must be a PNG, JPEG, or WebP file"}
return nil
}
+183
View File
@@ -0,0 +1,183 @@
package fleet
import (
"bytes"
"fmt"
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidateOrgLogoBytesSVG(t *testing.T) {
t.Parallel()
const minSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>`
t.Run("accepts a minimal SVG", func(t *testing.T) {
require.NoError(t, ValidateOrgLogoBytes([]byte(minSVG)))
})
t.Run("accepts an SVG with XML declaration and inline style", func(t *testing.T) {
body := `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">
<style>.a { fill: red; }</style>
<rect class="a" width="10" height="10"/>
</svg>`
require.NoError(t, ValidateOrgLogoBytes([]byte(body)))
})
t.Run("accepts xlink:href fragment references", func(t *testing.T) {
body := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><circle id="c" r="5"/></defs><use xlink:href="#c"/></svg>`
require.NoError(t, ValidateOrgLogoBytes([]byte(body)))
})
t.Run("accepts a real-world SVG (CSS logo from the public web)", func(t *testing.T) {
body, err := os.ReadFile("testdata/icons/org_logo_css.svg")
require.NoError(t, err)
require.NoError(t, ValidateOrgLogoBytes(body))
})
t.Run("rejects oversized SVG before parsing", func(t *testing.T) {
// Bytes don't need to be a real SVG — the size gate fires
// first regardless of looksLikeSVG.
body := append([]byte("<svg>"), bytes.Repeat([]byte("a"), int(OrgLogoMaxFileSize))...)
err := ValidateOrgLogoBytes(body)
require.Error(t, err)
assert.Contains(t, err.Error(), "100KB or less")
})
t.Run("rejects <script>", func(t *testing.T) {
body := `<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`
err := ValidateOrgLogoBytes([]byte(body))
require.Error(t, err)
assert.Contains(t, strings.ToLower(err.Error()), "<script>")
})
t.Run("rejects <foreignObject>", func(t *testing.T) {
body := `<svg xmlns="http://www.w3.org/2000/svg"><foreignObject><div xmlns="http://www.w3.org/1999/xhtml">x</div></foreignObject></svg>`
err := ValidateOrgLogoBytes([]byte(body))
require.Error(t, err)
assert.Contains(t, strings.ToLower(err.Error()), "foreignobject")
})
t.Run("rejects SMIL animation elements", func(t *testing.T) {
// SMIL <set>/<animate> can rewrite an ancestor's href to
// javascript:... after the static allowlist already passed,
// so the validator must reject these structurally.
for _, tag := range []string{"set", "animate", "animateTransform", "animateMotion"} {
t.Run(tag, func(t *testing.T) {
body := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg"><a href="#safe"><%s attributeName="href" to="javascript:alert(1)"/><rect width="1" height="1"/></a></svg>`, tag)
err := ValidateOrgLogoBytes([]byte(body))
require.Error(t, err)
assert.Contains(t, strings.ToLower(err.Error()), strings.ToLower(tag))
})
}
})
t.Run("rejects on* event handlers", func(t *testing.T) {
body := `<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><rect width="1" height="1"/></svg>`
err := ValidateOrgLogoBytes([]byte(body))
require.Error(t, err)
assert.Contains(t, err.Error(), "event-handler")
})
t.Run("href/src URL schemes", func(t *testing.T) {
// Allowlist: only fragment, relative, or http(s) are safe.
cases := []struct {
name string
href string
ok bool
}{
{"fragment", "#defs-id", true},
{"relative", "./icon.png", true},
{"https", "https://example.com/x.png", true},
{"http", "http://example.com/x.png", true},
{"javascript", "javascript:alert(1)", false},
{"vbscript", "vbscript:msgbox(1)", false},
{"data", "data:text/html,&lt;script&gt;a()&lt;/script&gt;", false},
{"file", "file:///etc/passwd", false},
{"livescript", "livescript:alert(1)", false},
{"uppercase javascript", "JAVASCRIPT:alert(1)", false},
{"leading whitespace + javascript", " javascript:alert(1)", false},
// url.Parse("//host/x") returns Scheme=="" + Host=="host";
// the browser resolves the page's scheme on click.
{"protocol-relative", "//evil.com/x.png", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
body := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><a xlink:href=%q><rect width="1" height="1"/></a></svg>`, c.href)
err := ValidateOrgLogoBytes([]byte(body))
if c.ok {
require.NoError(t, err)
} else {
require.Error(t, err)
assert.Contains(t, err.Error(), "fragment")
}
})
}
})
t.Run("rejects xml:base with unsafe scheme", func(t *testing.T) {
// xml:base re-anchors every relative href in the subtree, so
// `xml:base="javascript:"` + `href="alert(1)"` resolves to a
// javascript: URL and bypasses the static href check.
body := `<svg xmlns="http://www.w3.org/2000/svg" xml:base="javascript:"><a href="alert(1)"><rect width="1" height="1"/></a></svg>`
err := ValidateOrgLogoBytes([]byte(body))
require.Error(t, err)
assert.Contains(t, err.Error(), "fragment")
})
t.Run("rejects xml-stylesheet PI", func(t *testing.T) {
body := `<?xml version="1.0"?><?xml-stylesheet href="https://evil.example/x.xsl"?><svg xmlns="http://www.w3.org/2000/svg"/>`
err := ValidateOrgLogoBytes([]byte(body))
require.Error(t, err)
assert.Contains(t, err.Error(), "xml-stylesheet")
})
t.Run("rejects DOCTYPE", func(t *testing.T) {
body := `<?xml version="1.0"?>
<!DOCTYPE svg [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<svg xmlns="http://www.w3.org/2000/svg"><text>&xxe;</text></svg>`
err := ValidateOrgLogoBytes([]byte(body))
require.Error(t, err)
assert.Contains(t, strings.ToLower(err.Error()), "doctype")
})
t.Run("rejects malformed XML", func(t *testing.T) {
body := `<svg xmlns="http://www.w3.org/2000/svg"><rect`
err := ValidateOrgLogoBytes([]byte(body))
require.Error(t, err)
assert.Contains(t, err.Error(), "valid SVG")
})
t.Run("rejects non-svg root that still tripped the sniffer", func(t *testing.T) {
body := `<html><body><svg/></body></html>`
err := ValidateOrgLogoBytes([]byte(body))
require.Error(t, err)
assert.Contains(t, err.Error(), "root")
})
}
func TestContentTypeForOrgLogoSVG(t *testing.T) {
t.Parallel()
cases := []struct {
name string
body string
want string
}{
{"plain svg", `<svg xmlns="http://www.w3.org/2000/svg"/>`, "image/svg+xml"},
{"svg after xml decl", `<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"/>`, "image/svg+xml"},
{"svg after BOM and whitespace", "\xEF\xBB\xBF\n <svg/>", "image/svg+xml"},
{"uppercase root tag", "<SVG/>", "image/svg+xml"},
{"not an svg", `<html><body/></html>`, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, ContentTypeForOrgLogo([]byte(tc.body)))
})
}
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="256px" height="256px" viewBox="0 0 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<title>CSS</title>
<g>
<path d="M0,0 L215.04,0 C237.661583,0 256,18.3384166 256,40.96 L256,215.04 C256,237.661583 237.661583,256 215.04,256 L40.96,256 C18.3384166,256 0,237.661583 0,215.04 L0,0 Z" fill="#663399"></path>
<path d="M91.6736,235.52 C75.23072,235.50464 65.08544,226.24512 65.28,209.20576 C65.28,209.20576 65.28,166.09792 65.28,166.09792 C65.28,157.46048 67.80928,150.89152 72.86784,146.39616 C81.95328,137.64352 103.03232,137.11104 111.93088,146.52928 C117.4144,151.36512 119.48288,161.24416 118.99136,170.46272 L100.11904,170.46272 C100.2624,166.84032 100.0704,161.35424 97.94048,159.28832 C95.17312,155.54304 87.86432,155.98336 85.93152,159.88224 C84.74368,161.9968 84.14976,165.12768 84.14976,169.26976 L84.14976,206.69184 C84.14976,214.53824 86.87616,218.50624 92.33152,218.59328 C94.88128,218.59328 96.8192,217.66656 98.1376,215.81568 C99.97824,213.6192 100.26496,208.7552 100.11648,205.23776 L118.9888,205.23776 C120.27904,223.17568 109.69088,235.74272 91.67104,235.52 L91.6736,235.52 Z M151.58784,235.52 C133.36832,235.77088 125.21728,222.80704 125.7216,205.23776 L143.53664,205.23776 C143.04256,212.89728 145.67424,219.87328 151.98208,219.38688 C154.79552,219.38688 156.77696,218.50624 157.92128,216.7424 C160.09728,213.51936 160.50432,204.2112 157.39392,200.60928 C155.22048,197.14048 147.40224,194.07872 143.27296,192.01536 C137.37728,189.19424 133.1328,185.84576 130.53696,181.9648 C124.68224,173.35296 125.22496,154.44992 133.44,146.7904 C141.42208,137.33888 162.26816,136.90368 170.25792,146.59328 C175.18592,151.58784 177.33888,161.41568 176.9216,170.46016 L159.76448,170.46016 C159.9104,166.74304 159.55968,160.74496 158.11584,158.55872 C157.10464,156.70784 155.18976,155.78112 152.37632,155.78112 C147.36128,155.78112 144.85504,158.77888 144.85504,164.77184 C144.9088,171.136 147.38944,173.7472 153.16992,176.40704 C160.65536,179.3152 170.17344,184.2816 173.62432,190.29248 C183.91552,208.60672 176.85504,236.5824 151.58528,235.51488 L151.58784,235.52 Z M209.1264,235.52 C190.90688,235.77088 182.75584,222.80704 183.26016,205.23776 L201.0752,205.23776 C200.58112,212.89728 203.2128,219.87328 209.52064,219.38688 C212.33408,219.38688 214.31552,218.50624 215.45984,216.7424 C217.63584,213.51936 218.04288,204.2112 214.93248,200.60928 C212.75904,197.14048 204.9408,194.07872 200.81152,192.01536 C194.91584,189.19424 190.67136,185.84576 188.07552,181.9648 C182.2208,173.35296 182.76352,154.44992 190.97856,146.7904 C198.96064,137.33888 219.80672,136.90368 227.79648,146.59328 C232.72448,151.58784 234.87744,161.41568 234.46016,170.46016 L217.30304,170.46016 C217.44896,166.74304 217.09824,160.74496 215.6544,158.55872 C214.6432,156.70784 212.72832,155.78112 209.91488,155.78112 C204.89984,155.78112 202.3936,158.77888 202.3936,164.77184 C202.44736,171.136 204.928,173.7472 210.70848,176.40704 C218.19392,179.3152 227.712,184.2816 231.16288,190.29248 C241.45408,208.60672 234.3936,236.5824 209.12384,235.51488 L209.1264,235.52 Z" fill="#FFFFFF"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+6 -2
View File
@@ -51,10 +51,14 @@ func TestValidateOrgLogoFile(t *testing.T) {
t.Run("accepts jpeg", func(t *testing.T) {
assert.NoError(t, validateOrgLogoFile(writeTempFile(t, "logo.jpg", makeJPEG(t))))
})
t.Run("accepts svg", func(t *testing.T) {
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>`)
assert.NoError(t, validateOrgLogoFile(writeTempFile(t, "logo.svg", svg)))
})
t.Run("rejects unknown format", func(t *testing.T) {
err := validateOrgLogoFile(writeTempFile(t, "logo.txt", []byte("not an image")))
require.Error(t, err)
assert.ErrorContains(t, err, "PNG, JPEG, or WebP")
assert.ErrorContains(t, err, "PNG, JPEG, WebP, or SVG")
})
t.Run("rejects oversized file", func(t *testing.T) {
// fleet.ValidateOrgLogoBytes fires its size check before
@@ -243,7 +247,7 @@ func TestPlanAndStripOrgLogos(t *testing.T) {
})
_, err := c.planAndStripOrgLogos(settings, &fleet.OrgInfo{}, dir, false, logFn)
require.Error(t, err)
assert.ErrorContains(t, err, "PNG, JPEG, or WebP")
assert.ErrorContains(t, err, "PNG, JPEG, WebP, or SVG")
})
t.Run("dry run still validates and logs would-upload", func(t *testing.T) {
+13 -2
View File
@@ -119,6 +119,15 @@ func (r getOrgLogoResponse) HijackRender(_ context.Context, w http.ResponseWrite
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(r.Body)))
w.Header().Set("Cache-Control", "no-store")
// nosniff: stops the browser from MIME-sniffing the body as HTML
// (XSS vector) if upstream Content-Type ever drifts.
w.Header().Set("X-Content-Type-Options", "nosniff")
if contentType == "image/svg+xml" {
// CSP keeps the direct-URL view inert (where SVG loads as a
// document, not <img>). 'unsafe-inline' allows the inline
// <style> blocks most SVGs include.
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
}
_, _ = w.Write(r.Body)
}
@@ -301,8 +310,10 @@ func (svc *Service) GetOrgLogo(ctx context.Context, mode fleet.OrgLogoMode) ([]b
if int64(len(body)) > orgLogoMaxFileSize {
return nil, 0, ctxerr.New(ctx, "stored org logo exceeds max size")
}
if fleet.ContentTypeForOrgLogo(body) == "" {
return nil, 0, ctxerr.New(ctx, "stored org logo is not a recognized image format")
// Re-validate on read so a blob planted directly in the object store
// (bypassing the upload API) is still rejected.
if err := fleet.ValidateOrgLogoBytes(body); err != nil {
return nil, 0, ctxerr.Wrap(ctx, err, "stored org logo failed validation")
}
return body, int64(len(body)), nil
}