fix(storage): stop guest uploads from becoming same-origin script execution - #40
Merged
Merged
Conversation
…cution Uploaded assets are served from the application's own origin, so the extension stored in the key decides how a browser interprets the bytes. Nothing validated it: /api/message/upload_attachment accepted any file from an authenticated guest and the storage layer wrote whatever extension the client named, so a planted .html file ran against the dashboard origin the moment a staff member opened the attachment link. - Reject browser-active extensions and payloads (HTML, SVG, XML/XSL, scripts, legacy server-side pages) in AssetService.UploadFile and UploadBytes, and sniff the leading bytes instead of trusting the client-declared Content-Type so renaming a page to .png does not smuggle it through - Reduce client filenames to a bare basename within the 255 character column, cut on a rune boundary, and strip control characters - Derive the stored extension from an explicit MIME table. mime.ExtensionsByType consults the Windows registry, where text/html resolved to .ehtml and escaped the block list, and it differs between dev machines and production - Fall back to an inert .bin when no safe extension can be determined, so the server never sniffs the payload to decide its own Content-Type - Serve local assets with X-Content-Type-Options nosniff and force a download for anything that is not previewable media, which also neutralises document types already sitting in an existing storage root - Add error.e0348 to both backend locales for the rejection
This was referenced Sep 13, 2026
Merged
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this fixes
/api/message/upload_attachmentaccepts a file from any authenticated guest and hands it straight toAssetService.UploadFile, which stored it under whatever extension the client named and recorded whateverContent-Typethe client declared. Local assets are served byapp.StaticFSfrom the application's own origin, so a visitor could plantevil.html, get back/storage/<env>/attachments/<date>/<uuid>.html, and have it execute in the same security context as the dashboard the moment a staff member opened the attachment link.The image endpoint is not much better off: it checks
strings.HasPrefix(header.Header.Get("Content-Type"), "image/"), which is a client-declared header rather than a property of the bytes.Changes
Upload-time policy —
internal/services/storage/safety.go(new)ValidateUploadrejects browser-active extensions (HTML, SVG, XML/XSL, JS, PHP/ASP/JSP, HTA/HTC, SWF) and browser-active payloads by sniffed or declared media type, so renaming a page to.pngdoes not get it through.SniffContentTypereads the leading 512 bytes withnet/http.DetectContentTypeand rewinds, so the recorded MIME type describes the payload instead of the claim.UploadFilenow uses it;UploadBytesalready did.SanitizeFilenamereduces a client-supplied name to a bare basename, strips control characters, and caps it at 255 bytes on a rune boundary to match thevarchar(255)column. Uploads arrive from browsers, mobile SDKs and channel webhooks, all of which are free to send a full path.Storage key —
internal/services/storage/utils.gogetExtnow only accepts an extension from an explicit MIME table and falls back to an inert.bin. The fallback matters twice over: a key with no extension at all would leavehttp.ServeContentto sniff the payload and announcetext/htmlby itself.getExtByMimeTypeno longer callsmime.ExtensionsByType. That function consults the Windows registry, so on a Windows dev machinetext/htmlresolved to.ehtml— which escaped the block list entirely — and the same upload produced a different storage key than it does in production.TestGetExtByMimeTypeIsPlatformIndependentlocks the mapping down.Serve time —
internal/bootstrap/server.goX-Content-Type-Options: nosniff, plusContent-Disposition: attachmentfor anything that is not previewable media. This also neutralises document types that are already sitting in an existing deployment's storage root, which the upload-time policy alone cannot reach. Images, audio, video and PDF still render inline, and<img src>is unaffected byContent-Disposition.Locales — new
error.e0348in bothzh-CN.ymlanden-US.yml.Compatibility
Ordinary support-desk files are unaffected: images, PDF, Office and OpenDocument files, archives, audio, video, CSV, JSON, Markdown, logs and plain text all still upload. Opaque binaries such as
.apkor.dmgstill upload too — they are simply served as downloads, which is what a browser did with them anyway.SVG is now rejected, and that is the one visible behaviour change. Nothing in this repository uploads an SVG asset (
COMPANY_LOGO_URLpoints at a static file inweb/publicor at an external URL), and an SVG served from this origin is a script carrier. The attachment link inweb/lib/im-message.tsalready carriesdownload="{filename}", so forcing a download matches the intended UX rather than fighting it.Tests
internal/services/storage/safety_test.go(new) — 10 tests: the block lists, an HTML payload disguised as a PNG, an SVG declared as an image, ordinary files that must keep working, filename sanitising, reader rewinding after sniffing, short and empty payloads, storage-key generation, and the platform-independent MIME table.internal/bootstrap/server_route_test.go—TestNewServerHardensStoredAssetResponsesserves a real temp directory and asserts the response headers, including for an.htmlfile that predates the policy.go build,go vetandgo testall pass over./internal/services/... ./internal/repositories/... ./internal/pkg/... ./internal/oidcclient/... ./internal/migration/... ./internal/bootstrap/... ./internal/builders/... ./internal/handlers/....