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; + } + }