Skip to content

fix(storage): stop guest uploads from becoming same-origin script execution - #40

Merged
bbbbbbbbbbbbba merged 1 commit into
huabeitech:mainfrom
DOS:fix/upload-file-safety
Sep 14, 2026
Merged

bbbbbbbbbbbbba merged 1 commit into
huabeitech:mainfrom
DOS:fix/upload-file-safety

Conversation

@JOY

@JOY JOY commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

What this fixes

/api/message/upload_attachment accepts a file from any authenticated guest and hands it straight to AssetService.UploadFile, which stored it under whatever extension the client named and recorded whatever Content-Type the client declared. Local assets are served by app.StaticFS from the application's own origin, so a visitor could plant evil.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 policyinternal/services/storage/safety.go (new)

  • ValidateUpload rejects 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 .png does not get it through.
  • SniffContentType reads the leading 512 bytes with net/http.DetectContentType and rewinds, so the recorded MIME type describes the payload instead of the claim. UploadFile now uses it; UploadBytes already did.
  • SanitizeFilename reduces a client-supplied name to a bare basename, strips control characters, and caps it at 255 bytes on a rune boundary to match the varchar(255) column. Uploads arrive from browsers, mobile SDKs and channel webhooks, all of which are free to send a full path.

Storage keyinternal/services/storage/utils.go

  • getExt now 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 leave http.ServeContent to sniff the payload and announce text/html by itself.
  • getExtByMimeType no longer calls mime.ExtensionsByType. That function consults the Windows registry, so on a Windows dev machine text/html resolved to .ehtml — which escaped the block list entirely — and the same upload produced a different storage key than it does in production. TestGetExtByMimeTypeIsPlatformIndependent locks the mapping down.

Serve timeinternal/bootstrap/server.go

  • Local assets are served with X-Content-Type-Options: nosniff, plus Content-Disposition: attachment for 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 by Content-Disposition.

Locales — new error.e0348 in both zh-CN.yml and en-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 .apk or .dmg still 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_URL points at a static file in web/public or at an external URL), and an SVG served from this origin is a script carrier. The attachment link in web/lib/im-message.ts already carries download="{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.goTestNewServerHardensStoredAssetResponses serves a real temp directory and asserts the response headers, including for an .html file that predates the policy.

go build, go vet and go test all pass over ./internal/services/... ./internal/repositories/... ./internal/pkg/... ./internal/oidcclient/... ./internal/migration/... ./internal/bootstrap/... ./internal/builders/... ./internal/handlers/....

…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants