Skip to content

Derive media content types from file content, not the request - #174

Open
snoopdave wants to merge 1 commit into
masterfrom
media-content-type-handling
Open

Derive media content types from file content, not the request#174
snoopdave wants to merge 1 commit into
masterfrom
media-content-type-handling

Conversation

@snoopdave

Copy link
Copy Markdown
Contributor

This change derives a stored media file's content type from the file itself
rather than from the type declared with the upload, and serves media inline only
for a small explicit allow-list of passive formats.

What changed

  • Add MediaTypePolicy as the single place for the stored type, the inline
    allow-list, and the response headers.
  • Derive the stored type from the filename; consult the declared type only for
    opaque names and never adopt an active type (an explicit list plus any +xml
    suffix).
  • Send X-Content-Type-Options: nosniff on every media response.
  • Serve inline only passive images, audio, video, and PDF; serve everything else
    as application/octet-stream with an attachment disposition. SVG is excluded.
  • Route all five upload entry points and the three serving paths through the
    policy.

Note for the release notes: media held as CSS or JavaScript now downloads
instead of loading inline — a user-visible compatibility change.

Tests

MediaTypePolicyTest (9 behavioral cases plus 3 source audits asserting every
caller routes through the policy) covers a file whose declared type does not
match its name, SVG / XHTML and unknown types, genuine images retaining their
type, and nosniff on every response.

The type an upload declares is treated as a hint, and the stored type is
derived from the file name through one shared MediaTypePolicy. Serving applies
the other half of the policy: only a short list of passively-rendered formats is
sent inline, everything else is sent as an attachment, and every media response
carries nosniff.

All paths that accept an upload and all paths that serve uploaded media route
through the policy, including the entry editor's replacement-body path and the
resource servlets' uploaded-media fallback.

Files whose type is outside the inline list, such as CSS and JavaScript held as
media, now download rather than render. That is a behaviour change and belongs in
the release notes.

Claude-Session: https://claude.ai/code/session_01A1fhY1E2PCFU6UAPXu2WtV

@mraible mraible left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deriving the stored type from the file and refusing to adopt active types is the right model, and MediaTypePolicy is a good single home for it. Two things block it as-is, and a few behaviour changes need disclosing:

  • Customized themes break. WeblogCustomTheme.getResource() never assigns the media file it looks up, so it always returns null (pre-existing bug), and ThemeManagerImpl.importTheme stores theme resources as media files. Every /resource/css/*.css and js/*.js of a customized theme therefore takes the fromUploadedMedia branch in ResourceServlet / PreviewResourceServlet and is now served as application/octet-stream + Content-Disposition: attachment + nosniff. Browsers refuse stylesheets and scripts with a mismatched type under nosniff, so those blogs render unstyled and without jQuery/Bootstrap after upgrade. (inline)
  • The AtomPub edit-media GET (MediaCollection.getMediaResource, served by propono's AtomServlet) isn't routed through the policy: it sets Content-Type from the file name via JAF with no nosniff and no disposition, so payload.html uploaded through any of the five policed entry points is still served as text/html at /roller-services/app/<handle>/resource/payload.html to anyone with cached Basic auth for the realm. Either apply applyResponseHeaders there or state that the AtomPub path is out of scope.

Behaviour changes to disclose (the description only mentions CSS/JS): existing image/svg+xml media now downloads instead of rendering (every <img src=".../logo.svg"> in old entries breaks) and new SVG uploads are stored as application/octet-stream so they vanish from the image chooser; image/x-png (legacy IE uploads), image/jpg, image/avif, image/apng and text/plain also become forced downloads; and uploads with no declared type were previously rejected by checkFileType but are now accepted as application/octet-stream.

String resourceType = this.context.getMimeType(
resourceRequest.getResourcePath());
if (fromUploadedMedia) {
// Uploaded through the media library, so it is governed by the

@mraible mraible Aug 31, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch is also where customized-theme resources land: WeblogCustomTheme.getResource() looks up the media file but never assigns it to resource, so it always returns null, and importTheme stores theme CSS/JS as media files with their original path. resourceType for css/bootstrap.css is text/css, which isn't inline-safe, so the stylesheet goes out as an octet-stream attachment with nosniff and the browser drops it. Fixing WeblogCustomTheme.getResource to return the media file would route these through the theme branch above; alternatively, treat text/css / text/javascript from the servlet-context mime table as theme-authored here as the description already promises.

* Formats browsers render without executing anything the file carries.
* SVG is deliberately absent: it is an XML document that can carry script.
*/
private static final Set<String> INLINE_TYPES = Collections.unmodifiableSet(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth listing what this drops compared to response.setContentType(mediaFile.getContentType()) on master. Existing image/svg+xml media (already stored, already embedded in entries with <img>) now downloads instead of rendering, since browsers never sniff SVG; and image/x-png (legacy IE uploads), image/jpg (some XML-RPC clients), image/avif and image/apng are all passive but absent here. If SVG stays out (defensible, it can carry script), the release note should say so explicitly, and I'd add the other image subtypes.

}

/** @return true when browsers render this type without executing it */
public static boolean isInlineSafe(String contentType) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

text/plain with nosniff can't execute anything, so uploaded .txt files (very common as attachments) could stay inline rather than becoming a save-as dialog for an application/octet-stream notes.txt.

// Replacing the body re-decides the type, on the same
// terms as the original upload.
mediaFile.setContentType(MediaTypePolicy.storedTypeFor(
mediaFile.getName(), this.uploadedFileContentType));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On replace this derives the type from the record's (old) name rather than the uploaded replacement's name (this.uploadedFileName is right there). Replacing photo.jpg with photo.png without renaming stores image/jpeg for PNG bytes and serves them with nosniff; replacing clip.txt with a video keeps text/plain and forces a download. putMedia in MediaCollection has the same mismatch with mf.getName().

* conclusive, otherwise the declared type if it is not one a
* browser would act on, otherwise the generic binary type
*/
public static String storedTypeFor(String fileName, String declaredType) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because active declared types collapse to application/octet-stream before canSave / checkFileType run, an admin's content-type rules in uploads.types.forbid (e.g. image/svg+xml,application/xhtml+xml) no longer match those uploads; the file is stored under the generic type instead of being refused. Run the forbid check against the declared type too, or document that forbid rules are now extension-based.

}
try {
return Utilities.getContentTypeFromFileName(fileName);
} catch (Exception undetermined) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Utilities.getContentTypeFromFileName is backed by javax.activation's default map, which knows about 22 extensions (no pdf, svg, webp, mp4, mp3, zip, css, js). So "the name decides" only holds for those; for everything else the declared type is stored after all (a report.pdf declared application/zip is stored as zip and downloads). The servlets already use the servlet context's mime table (web.xml has a full one); the policy should consult the same table.

mf.setWeblog(website);
mf.setName(name);
mf.setContentType(type);
mf.setContentType(MediaTypePolicy.storedTypeFor(name, type));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously mf.setContentType(null) made checkFileType reject an upload with no type member; storedTypeFor(name, null) now always returns a type, so the upload is accepted as application/octet-stream when the allow list is empty (the default). Probably fine, but it's a loosening the description should mention.

if (fromUploadedMedia) {
// Uploaded through the media library, so it is governed by the
// same policy as any other media response.
MediaTypePolicy.applyResponseHeaders(response, resourceType,

@mraible mraible Aug 31, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

context.getMimeType() returns null for extensions not mapped in web.xml/the container, and applyResponseHeaders turns null into an octet-stream attachment. On master setContentType(null) left the type unset and the browser could still display the file; now any unmapped (or uppercase, on a case-sensitive container) extension downloads in the theme preview.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants