From 5498cc6c8b4d2258476028a4457835f07c715cea Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Wed, 2 Sep 2026 10:35:50 -0500 Subject: [PATCH] Add more strict rules about various extensions and provide more instructions to the browser which to render. Resolves bug where certain ms office files would show up binary in the browser. controls/hla/bely#224 --- .../rest/constants/DownloadRouteMimeType.java | 83 ++++++++++++------- .../aps/logr/rest/routes/DownloadRoute.java | 55 ++++++++++-- 2 files changed, 102 insertions(+), 36 deletions(-) diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/constants/DownloadRouteMimeType.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/constants/DownloadRouteMimeType.java index c9e6dcf38..8a38c34a1 100644 --- a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/constants/DownloadRouteMimeType.java +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/constants/DownloadRouteMimeType.java @@ -6,51 +6,76 @@ public enum DownloadRouteMimeType { - jpg(new String[]{"jpg", "jpeg"}, "image/jpeg"), - png(new String[]{"png"}, "image/png"), - gif(new String[]{"gif"}, "image/gif"), - pdf(new String[]{"pdf"}, "application/pdf"), - html(new String[]{"htm", "html"}, "text/html"), - mp4(new String[]{"mp4", "mov"}, "video/mp4"), - mpeg(new String[]{"mpeg", "mpg"}, "video/mpeg"), - webm(new String[]{"webm"}, "video/webm"), - ogv(new String[]{"ogv"}, "video/ogg"), - wildcard(new String[]{""}, "*/*"); + // Formats browsers can safely render in a tab. + jpg(new String[]{"jpg", "jpeg"}, "image/jpeg", true), + png(new String[]{"png"}, "image/png", true), + gif(new String[]{"gif"}, "image/gif", true), + pdf(new String[]{"pdf"}, "application/pdf", true), + mp4(new String[]{"mp4", "mov"}, "video/mp4", true), + mpeg(new String[]{"mpeg", "mpg"}, "video/mpeg", true), + webm(new String[]{"webm"}, "video/webm", true), + ogv(new String[]{"ogv"}, "video/ogg", true), + + // Rendering uploaded html inline would run its script in the app origin. + html(new String[]{"htm", "html"}, "text/html", false), + + // Correct type so the OS picks the right app, but always delivered as a download. + docx(new String[]{"docx"}, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", false), + xlsx(new String[]{"xlsx"}, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", false), + pptx(new String[]{"pptx"}, "application/vnd.openxmlformats-officedocument.presentationml.presentation", false), + doc(new String[]{"doc"}, "application/msword", false), + xls(new String[]{"xls"}, "application/vnd.ms-excel", false), + ppt(new String[]{"ppt"}, "application/vnd.ms-powerpoint", false), + odt(new String[]{"odt"}, "application/vnd.oasis.opendocument.text", false), + ods(new String[]{"ods"}, "application/vnd.oasis.opendocument.spreadsheet", false), + odp(new String[]{"odp"}, "application/vnd.oasis.opendocument.presentation", false), + zip(new String[]{"zip"}, "application/zip", false), + csv(new String[]{"csv"}, "text/csv", false), + txt(new String[]{"txt"}, "text/plain", false), + json(new String[]{"json"}, "application/json", false), + xml(new String[]{"xml"}, "application/xml", false), + + // Unrecognized extensions force a download rather than letting the browser guess. + unknown(new String[]{""}, "application/octet-stream", false); private String[] extension; private String mimeType; + private boolean inlineViewable; - private DownloadRouteMimeType(String[] ext, String value) { + private DownloadRouteMimeType(String[] ext, String value, boolean inlineViewable) { this.extension = ext; this.mimeType = value; + this.inlineViewable = inlineViewable; } - public static String getTypeForFilename(String fileName) { - String type = DownloadRouteMimeType.wildcard.mimeType; + private static DownloadRouteMimeType getForFilename(String fileName) { + if (fileName == null) { + return DownloadRouteMimeType.unknown; + } String[] split = fileName.split("[.]"); - String ext = null; - if (split.length > 0) { - ext = split[split.length - 1].toLowerCase(); - } else { - return type; + if (split.length < 2) { + return DownloadRouteMimeType.unknown; } - - DownloadRouteMimeType[] values = DownloadRouteMimeType.values(); - boolean done = false; - for (DownloadRouteMimeType value : values) { + String ext = split[split.length - 1].toLowerCase(); + + for (DownloadRouteMimeType value : DownloadRouteMimeType.values()) { for (String possibleExt : value.extension) { if (possibleExt.equals(ext)) { - type = value.mimeType; - done = true; - break; + return value; } } - if (done) { - break; - } } - return type; + return DownloadRouteMimeType.unknown; + } + + public static String getTypeForFilename(String fileName) { + return getForFilename(fileName).mimeType; + } + + /** Whether the browser may display the file inline rather than downloading it. */ + public static boolean isInlineViewable(String fileName) { + return getForFilename(fileName).inlineViewable; } }; diff --git a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/routes/DownloadRoute.java b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/routes/DownloadRoute.java index 12d28f889..48d014b4c 100644 --- a/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/routes/DownloadRoute.java +++ b/src/java/LogrPortal/src/java/gov/anl/aps/logr/rest/routes/DownloadRoute.java @@ -24,6 +24,9 @@ import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import javax.ejb.EJB; @@ -197,13 +200,11 @@ private Response getFileResponse(String errorFileTypeColonName, String fileName, File file = new File(storageFilePath); if (file.exists()) { - String headerObject = ""; - if (isAttachment) { - headerObject += "attachment; "; - } else { - headerObject += "inline; "; - } - headerObject += "filename=" + fileName; + // Callers may force a download; otherwise the resolved type decides. + boolean forceDownload = isAttachment || !DownloadRouteMimeType.isInlineViewable(fileName); + + String headerObject = forceDownload ? "attachment; " : "inline; "; + headerObject += buildFilenameHeaderParameters(fileName); ResponseBuilder response = null; @@ -214,6 +215,9 @@ private Response getFileResponse(String errorFileTypeColonName, String fileName, headerObject); response.header("Content-Length", file.length()); + + // Keep browsers from sniffing a different type than the one we declared. + response.header("X-Content-Type-Options", "nosniff"); return response.build(); } @@ -223,4 +227,41 @@ private Response getFileResponse(String errorFileTypeColonName, String fileName, throw fileNotFoundException; } + /** RFC 6266 filename parameters: quoted ASCII fallback plus RFC 5987 encoded form. */ + private String buildFilenameHeaderParameters(String fileName) { + if (fileName == null || fileName.isEmpty()) { + fileName = "download"; + } + + // Never let a path escape into the suggested name. + fileName = fileName.replace('\\', '/'); + int lastSeparator = fileName.lastIndexOf('/'); + if (lastSeparator >= 0) { + fileName = fileName.substring(lastSeparator + 1); + } + if (fileName.isEmpty()) { + fileName = "download"; + } + + StringBuilder asciiFallback = new StringBuilder(); + for (char c : fileName.toCharArray()) { + if (c < 32 || c > 126 || c == '"' || c == '\\') { + asciiFallback.append('_'); + } else { + asciiFallback.append(c); + } + } + + String encoded; + try { + encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()) + .replace("+", "%20"); + } catch (UnsupportedEncodingException ex) { + // UTF-8 is always available. + encoded = asciiFallback.toString(); + } + + return "filename=\"" + asciiFallback + "\"; filename*=UTF-8''" + encoded; + } + }