From 2596b808fa96976e668262db60fdca9f736f59b5 Mon Sep 17 00:00:00 2001 From: Neil Deakin Date: Tue, 3 May 2022 19:44:24 +0000 Subject: [PATCH] Bug 1746052, add methods to the mime service that compute and validate a filename for a given content type, r=Gijs The code in SanitizeFilename will be expanded upon in the following patch. Differential Revision: https://phabricator.services.mozilla.com/D135951 --- netwerk/mime/nsIMIMEService.idl | 100 +++ .../exthandler/nsExternalHelperAppService.cpp | 688 ++++++++++-------- .../exthandler/nsExternalHelperAppService.h | 42 +- 3 files changed, 503 insertions(+), 327 deletions(-) diff --git a/netwerk/mime/nsIMIMEService.idl b/netwerk/mime/nsIMIMEService.idl index 5a9a379b0f22..f931074cf1c3 100644 --- a/netwerk/mime/nsIMIMEService.idl +++ b/netwerk/mime/nsIMIMEService.idl @@ -8,6 +8,7 @@ interface nsIFile; interface nsIMIMEInfo; interface nsIURI; +interface nsIChannel; %{C++ #define NS_MIMESERVICE_CID \ @@ -97,4 +98,103 @@ interface nsIMIMEService : nsISupports { nsIMIMEInfo getMIMEInfoFromOS(in ACString aType, in ACString aFileExtension, out boolean aFound); + + /** + * Default filename validation for getValidFileName and + * validateFileNameForSaving where other flags are not true. + * That is, the extension is modified to fit the content type, + * duplicate whitespace is collapsed, and long filenames are + * truncated. A valid content type must be supplied. See the + * description of getValidFileName for more details about how + * the flags are used. + */ + const long VALIDATE_DEFAULT = 0; + + /** + * If true, then the filename is only validated to ensure that it is + * acceptable for the file system. If false, then the extension is also + * checked to ensure that it is valid for the content type. If the + * extension is not valid, the filename is modified to have the proper + * extension. + */ + const long VALIDATE_SANITIZE_ONLY = 1; + + /** + * Don't collapse strings of duplicate whitespace into a single string. + */ + const long VALIDATE_DONT_COLLAPSE_WHITESPACE = 2; + + /** + * Don't truncate long filenames. + */ + const long VALIDATE_DONT_TRUNCATE = 4; + + /** + * True to ignore the content type and guess the type from any existing + * extension instead. "application/octet-stream" is used as the default + * if there is no extension or there is no information available for + * the extension. + */ + const long VALIDATE_GUESS_FROM_EXTENSION = 8; + + /** + * Generate a valid filename from the channel that can be used to save + * the content of the channel to the local disk. + * + * The filename is determined from the content disposition, the filename + * of the uri, or a default filename. The following modifications are + * applied: + * - If the VALIDATE_SANITIZE_ONLY flag is not specified, then the + * extension of the filename is modified to suit the supplied content type. + * - Path separators (typically / and \) are replaced by underscores (_) + * - Characters that are not valid or would be confusing in filenames are + * replaced by spaces (*, :, etc) + * - Bidi related marks are replaced by underscores (_) + * - Whitespace and periods are removed from the beginning and end. + * - Unless VALIDATE_DONT_COLLAPSE_WHITESPACE is specified, multiple + * consecutive whitespace characters are collapsed to a single space + * character (' '). + * - Unless VALIDATE_DONT_TRUNCATE is specified, the filename is truncated + * to a maximum length, preserving the extension if possible. + * + * If either the VALIDATE_SANITIZE_ONLY or VALIDATE_GUESS_FROM_EXTENSION flags + * are specified, then the content type may be empty. Otherwise, the type must + * not be empty. + * + * The aOriginalURI would be specified if the channel is for a local file but + * it was originally sourced from a different uri. + * + * When saving an image, use validateFileNameForSaving instead and + * pass the result of imgIRequest::GetFileName() as the filename to + * check. + * + * @param aChannel The channel of the content to save. + * @param aType The MIME type to use, which would usually be the + * same as the content type of the channel. + * @param aOriginalURL the source url of the file, but may be null. + * @param aFlags one or more of the flags above. + * @returns The resulting filename. + */ + AString getValidFileName(in nsIChannel aChannel, + in ACString aType, + in nsIURI aOriginalURI, + in unsigned long aFlags); + + /** + * Similar to getValidFileName, but used when a specific filename needs + * to be validated. The filename is modified as needed based on the + * content type in the same manner as getValidFileName. + * + * If the filename came from a uri, it should not be escaped, that is, + * any needed unescaping of the filename should happen before calling + * this method. + * + * @param aType The MIME type to use. + * @param aFlags one or more of the flags above. + * @param aFileName The filename to validate. + * @returns The validated filename. + */ + AString validateFileNameForSaving(in AString aFileName, + in ACString aType, + in unsigned long aFlags); }; diff --git a/uriloader/exthandler/nsExternalHelperAppService.cpp b/uriloader/exthandler/nsExternalHelperAppService.cpp index 1ddfd9f652e5..a9d29a6910d4 100644 --- a/uriloader/exthandler/nsExternalHelperAppService.cpp +++ b/uriloader/exthandler/nsExternalHelperAppService.cpp @@ -114,6 +114,8 @@ using namespace mozilla; using namespace mozilla::ipc; using namespace mozilla::dom; +#define kDefaultMaxFileNameLength 255 + // Download Folder location constants #define NS_PREF_DOWNLOAD_DIR "browser.download.dir" #define NS_PREF_DOWNLOAD_FOLDERLIST "browser.download.folderList" @@ -179,108 +181,6 @@ static nsresult UnescapeFragment(const nsACString& aFragment, nsIURI* aURI, return rv; } -/** - * Given a channel, returns the filename and extension the channel has. - * This uses the URL and other sources (nsIMultiPartChannel). - * Also gives back whether the channel requested external handling (i.e. - * whether Content-Disposition: attachment was sent) - * @param aChannel The channel to extract the filename/extension from - * @param aFileName [out] Reference to the string where the filename should be - * stored. Empty if it could not be retrieved. - * WARNING - this filename may contain characters which the OS does not - * allow as part of filenames! - * @param aExtension [out] Reference to the string where the extension should - * be stored. Empty if it could not be retrieved. Stored in UTF-8. - * @param aAllowURLExtension (optional) Get the extension from the URL if no - * Content-Disposition header is present. Default is true. - * @retval true The server sent Content-Disposition:attachment or equivalent - * @retval false Content-Disposition: inline or no content-disposition header - * was sent. - */ -static bool GetFilenameAndExtensionFromChannel(nsIChannel* aChannel, - nsString& aFileName, - nsCString& aExtension, - bool aAllowURLExtension = true) { - aExtension.Truncate(); - /* - * If the channel is an http or part of a multipart channel and we - * have a content disposition header set, then use the file name - * suggested there as the preferred file name to SUGGEST to the - * user. we shouldn't actually use that without their - * permission... otherwise just use our temp file - */ - bool handleExternally = false; - uint32_t disp; - nsresult rv = aChannel->GetContentDisposition(&disp); - bool gotFileNameFromURI = false; - if (NS_SUCCEEDED(rv)) { - aChannel->GetContentDispositionFilename(aFileName); - if (disp == nsIChannel::DISPOSITION_ATTACHMENT) handleExternally = true; - } - - // If the disposition header didn't work, try the filename from nsIURL - nsCOMPtr uri; - aChannel->GetURI(getter_AddRefs(uri)); - nsCOMPtr url(do_QueryInterface(uri)); - if (url && aFileName.IsEmpty()) { - if (aAllowURLExtension) { - url->GetFileExtension(aExtension); - UnescapeFragment(aExtension, url, aExtension); - - // Windows ignores terminating dots. So we have to as well, so - // that our security checks do "the right thing" - // In case the aExtension consisted only of the dot, the code below will - // extract an aExtension from the filename - aExtension.Trim(".", false); - } - - // try to extract the file name from the url and use that as a first pass as - // the leaf name of our temp file... - nsAutoCString leafName; - url->GetFileName(leafName); - if (!leafName.IsEmpty()) { - gotFileNameFromURI = true; - rv = UnescapeFragment(leafName, url, aFileName); - if (NS_FAILED(rv)) { - CopyUTF8toUTF16(leafName, aFileName); // use escaped name - } - } - } - - // If we have a filename and no extension, remove trailing dots from the - // filename and extract the extension if that is possible. - if (aExtension.IsEmpty() && !aFileName.IsEmpty()) { - // Windows ignores terminating dots. So we have to as well, so - // that our security checks do "the right thing" - aFileName.Trim(".", false); - // We can get an extension if the filename is from a header, or if getting - // it from the URL was allowed. - bool canGetExtensionFromFilename = - !gotFileNameFromURI || aAllowURLExtension; - // ... , or if the mimetype is meaningless and we have nothing to go on: - if (!canGetExtensionFromFilename) { - nsAutoCString contentType; - if (NS_SUCCEEDED(aChannel->GetContentType(contentType))) { - canGetExtensionFromFilename = - contentType.EqualsIgnoreCase(APPLICATION_OCTET_STREAM) || - contentType.EqualsIgnoreCase("binary/octet-stream") || - contentType.EqualsIgnoreCase("application/x-msdownload"); - } - } - - if (canGetExtensionFromFilename) { - // XXX RFindCharInReadable!! - nsAutoString fileNameStr(aFileName); - int32_t idx = fileNameStr.RFindChar(char16_t('.')); - if (idx != kNotFound) - CopyUTF16toUTF8(StringTail(fileNameStr, fileNameStr.Length() - idx - 1), - aExtension); - } - } - - return handleExternally; -} - /** * Obtains the directory to use. This tends to vary per platform, and * needs to be consistent throughout our codepaths. For platforms where @@ -809,8 +709,10 @@ nsresult nsExternalHelperAppService::DoContentContentProcessHelper( uint32_t reason = nsIHelperAppLauncherDialog::REASON_CANTHANDLE; + SanitizeFileName(fileName, EmptyCString(), 0); + RefPtr handler = - new nsExternalAppHandler(nullptr, ""_ns, aContentContext, aWindowContext, + new nsExternalAppHandler(nullptr, u""_ns, aContentContext, aWindowContext, this, fileName, reason, aForceSave); if (!handler) { return NS_ERROR_OUT_OF_MEMORY; @@ -830,98 +732,32 @@ NS_IMETHODIMP nsExternalHelperAppService::CreateListener( nsAutoString fileName; nsAutoCString fileExtension; uint32_t reason = nsIHelperAppLauncherDialog::REASON_CANTHANDLE; - uint32_t contentDisposition = -1; - // Get the file extension and name that we will need later nsCOMPtr channel = do_QueryInterface(aRequest); - nsCOMPtr uri; - int64_t contentLength = -1; if (channel) { - channel->GetURI(getter_AddRefs(uri)); - channel->GetContentLength(&contentLength); + uint32_t contentDisposition = -1; channel->GetContentDisposition(&contentDisposition); - channel->GetContentDispositionFilename(fileName); - - // Check if we have a POST request, in which case we don't want to use - // the url's extension - bool allowURLExt = !net::ChannelIsPost(channel); - - // Check if we had a query string - we don't want to check the URL - // extension if a query is present in the URI - // If we already know we don't want to check the URL extension, don't - // bother checking the query - if (uri && allowURLExt) { - nsCOMPtr url = do_QueryInterface(uri); - - if (url) { - nsAutoCString query; - - // We only care about the query for HTTP and HTTPS URLs - if (uri->SchemeIs("http") || uri->SchemeIs("https")) { - url->GetQuery(query); - } - - // Only get the extension if the query is empty; if it isn't, then the - // extension likely belongs to a cgi script and isn't helpful - allowURLExt = query.IsEmpty(); - } - } - // Extract name & extension - bool isAttachment = GetFilenameAndExtensionFromChannel( - channel, fileName, fileExtension, allowURLExt); - LOG(("Found extension '%s' (filename is '%s', handling attachment: %i)", - fileExtension.get(), NS_ConvertUTF16toUTF8(fileName).get(), - isAttachment)); - if (isAttachment) { + if (contentDisposition == nsIChannel::DISPOSITION_ATTACHMENT) { reason = nsIHelperAppLauncherDialog::REASON_SERVERREQUEST; } } - LOG(("HelperAppService::DoContent: mime '%s', extension '%s'\n", - PromiseFlatCString(aMimeContentType).get(), fileExtension.get())); + *aStreamListener = nullptr; - // We get the mime service here even though we're the default implementation - // of it, so it's possible to override only the mime service and not need to - // reimplement the whole external helper app service itself. - nsCOMPtr mimeSvc(do_GetService(NS_MIMESERVICE_CONTRACTID)); - NS_ENSURE_TRUE(mimeSvc, NS_ERROR_FAILURE); + // Get the file extension and name that we will need later + nsCOMPtr uri; + bool allowURLExtension = + GetFileNameFromChannel(channel, fileName, getter_AddRefs(uri)); - // Try to find a mime object by looking at the mime type/extension - nsCOMPtr mimeInfo; + uint32_t flags = VALIDATE_DEFAULT; if (aMimeContentType.Equals(APPLICATION_GUESS_FROM_EXT, nsCaseInsensitiveCStringComparator)) { - nsAutoCString mimeType; - if (!fileExtension.IsEmpty()) { - mimeSvc->GetFromTypeAndExtension(""_ns, fileExtension, - getter_AddRefs(mimeInfo)); - if (mimeInfo) { - mimeInfo->GetMIMEType(mimeType); - - LOG(("OS-Provided mime type '%s' for extension '%s'\n", mimeType.get(), - fileExtension.get())); - } - } - - if (fileExtension.IsEmpty() || mimeType.IsEmpty()) { - // Extension lookup gave us no useful match - mimeSvc->GetFromTypeAndExtension( - nsLiteralCString(APPLICATION_OCTET_STREAM), fileExtension, - getter_AddRefs(mimeInfo)); - mimeType.AssignLiteral(APPLICATION_OCTET_STREAM); - } - - if (channel) { - channel->SetContentType(mimeType); - } - - // Don't overwrite SERVERREQUEST - if (reason == nsIHelperAppLauncherDialog::REASON_CANTHANDLE) { - reason = nsIHelperAppLauncherDialog::REASON_TYPESNIFFED; - } - } else { - mimeSvc->GetFromTypeAndExtension(aMimeContentType, fileExtension, - getter_AddRefs(mimeInfo)); + flags = VALIDATE_GUESS_FROM_EXTENSION; } + + nsCOMPtr mimeInfo = ValidateFileNameForSaving( + fileName, aMimeContentType, uri, nullptr, flags, allowURLExtension); + LOG(("Type/Ext lookup found 0x%p\n", mimeInfo.get())); // No mimeinfo -> we can't continue. probably OOM. @@ -929,17 +765,30 @@ NS_IMETHODIMP nsExternalHelperAppService::CreateListener( return NS_ERROR_OUT_OF_MEMORY; } - *aStreamListener = nullptr; - // We want the mimeInfo's primary extension to pass it to - // nsExternalAppHandler - nsAutoCString buf; - mimeInfo->GetPrimaryExtension(buf); + if (flags & VALIDATE_GUESS_FROM_EXTENSION) { + if (channel) { + // Replace the content type with what was guessed. + nsAutoCString mimeType; + mimeInfo->GetMIMEType(mimeType); + channel->SetContentType(mimeType); + } + + if (reason == nsIHelperAppLauncherDialog::REASON_CANTHANDLE) { + reason = nsIHelperAppLauncherDialog::REASON_TYPESNIFFED; + } + } + + nsAutoString extension; + int32_t dotidx = fileName.RFind("."); + if (dotidx != -1) { + extension = Substring(fileName, dotidx + 1); + } // NB: ExternalHelperAppParent depends on this listener always being an // nsExternalAppHandler. If this changes, make sure to update that code. - nsExternalAppHandler* handler = - new nsExternalAppHandler(mimeInfo, buf, aContentContext, aWindowContext, - this, fileName, reason, aForceSave); + nsExternalAppHandler* handler = new nsExternalAppHandler( + mimeInfo, extension, aContentContext, aWindowContext, this, fileName, + reason, aForceSave); if (!handler) { return NS_ERROR_OUT_OF_MEMORY; } @@ -1428,14 +1277,14 @@ NS_INTERFACE_MAP_BEGIN(nsExternalAppHandler) NS_INTERFACE_MAP_END nsExternalAppHandler::nsExternalAppHandler( - nsIMIMEInfo* aMIMEInfo, const nsACString& aTempFileExtension, + nsIMIMEInfo* aMIMEInfo, const nsAString& aFileExtension, BrowsingContext* aBrowsingContext, nsIInterfaceRequestor* aWindowContext, nsExternalHelperAppService* aExtProtSvc, - const nsAString& aSuggestedFilename, uint32_t aReason, bool aForceSave) + const nsAString& aSuggestedFileName, uint32_t aReason, bool aForceSave) : mMimeInfo(aMIMEInfo), mBrowsingContext(aBrowsingContext), mWindowContext(aWindowContext), - mSuggestedFileName(aSuggestedFilename), + mSuggestedFileName(aSuggestedFileName), mForceSave(aForceSave), mCanceled(false), mStopRequestIssued(false), @@ -1453,53 +1302,10 @@ nsExternalAppHandler::nsExternalAppHandler( mRequest(nullptr), mExtProtSvc(aExtProtSvc) { // make sure the extention includes the '.' - if (!aTempFileExtension.IsEmpty() && aTempFileExtension.First() != '.') - mTempFileExtension = char16_t('.'); - AppendUTF8toUTF16(aTempFileExtension, mTempFileExtension); - - // Get mSuggestedFileName's current file extension. - nsAutoString originalFileExt; - int32_t pos = mSuggestedFileName.RFindChar('.'); - if (pos != kNotFound) { - mSuggestedFileName.Right(originalFileExt, - mSuggestedFileName.Length() - pos); + if (!aFileExtension.IsEmpty() && aFileExtension.First() != '.') { + mFileExtension = char16_t('.'); } - - // replace platform specific path separator and illegal characters to avoid - // any confusion. - // Try to keep the use of spaces or underscores in sync with the Downloads - // code sanitization in DownloadPaths.jsm - mSuggestedFileName.ReplaceChar(KNOWN_PATH_SEPARATORS, '_'); - mSuggestedFileName.ReplaceChar(FILE_ILLEGAL_CHARACTERS, ' '); - mSuggestedFileName.ReplaceChar(char16_t(0), '_'); - mTempFileExtension.ReplaceChar(KNOWN_PATH_SEPARATORS, '_'); - mTempFileExtension.ReplaceChar(FILE_ILLEGAL_CHARACTERS, ' '); - - // Remove unsafe bidi characters which might have spoofing implications (bug - // 511521). - const char16_t unsafeBidiCharacters[] = { - char16_t(0x061c), // Arabic Letter Mark - char16_t(0x200e), // Left-to-Right Mark - char16_t(0x200f), // Right-to-Left Mark - char16_t(0x202a), // Left-to-Right Embedding - char16_t(0x202b), // Right-to-Left Embedding - char16_t(0x202c), // Pop Directional Formatting - char16_t(0x202d), // Left-to-Right Override - char16_t(0x202e), // Right-to-Left Override - char16_t(0x2066), // Left-to-Right Isolate - char16_t(0x2067), // Right-to-Left Isolate - char16_t(0x2068), // First Strong Isolate - char16_t(0x2069), // Pop Directional Isolate - char16_t(0)}; - mSuggestedFileName.ReplaceChar(unsafeBidiCharacters, '_'); - mTempFileExtension.ReplaceChar(unsafeBidiCharacters, '_'); - - // Remove trailing or leading spaces that we may have generated while - // sanitizing. - mSuggestedFileName.CompressWhitespace(); - mTempFileExtension.CompressWhitespace(); - - EnsureCorrectExtension(originalFileExt); + mFileExtension.Append(aFileExtension); mBufferSize = Preferences::GetUint("network.buffer.cache.size", 4096); } @@ -1508,80 +1314,6 @@ nsExternalAppHandler::~nsExternalAppHandler() { MOZ_ASSERT(!mSaver, "Saver should hold a reference to us until deleted"); } -bool nsExternalAppHandler::ShouldForceExtension(const nsString& aFileExt) { - nsAutoCString MIMEType; - if (!mMimeInfo || NS_FAILED(mMimeInfo->GetMIMEType(MIMEType))) { - return false; - } - - bool canForce = StringBeginsWith(MIMEType, "image/"_ns) || - StringBeginsWith(MIMEType, "audio/"_ns) || - StringBeginsWith(MIMEType, "video/"_ns); - - if (!canForce && - StaticPrefs::browser_download_sanitize_non_media_extensions()) { - for (const char* mime : forcedExtensionMimetypes) { - if (MIMEType.Equals(mime)) { - canForce = true; - break; - } - } - } - if (!canForce) { - return false; - } - - // If we get here, we know for sure the mimetype allows us to overwrite the - // existing extension, if it's wrong. Return whether the extension is wrong: - - bool knownExtension = false; - // Note that aFileExt is either empty or consists of an extension - // *including the dot* which we remove for ExtensionExists(). - return ( - aFileExt.IsEmpty() || aFileExt.EqualsLiteral(".") || - (NS_SUCCEEDED(mMimeInfo->ExtensionExists( - Substring(NS_ConvertUTF16toUTF8(aFileExt), 1), &knownExtension)) && - !knownExtension)); -} - -void nsExternalAppHandler::EnsureCorrectExtension(const nsString& aFileExt) { - // If we don't have an extension (which will include the .), - // just short-circuit. - if (mTempFileExtension.Length() <= 1) { - return; - } - - // After removing trailing whitespaces from the name, if we have a - // temp file extension, there are broadly 2 cases where we want to - // replace the extension. - // First, if the file extension contains invalid characters. - // Second, for document type mimetypes, if the extension is either - // missing or not valid for this mimetype. - bool replaceExtension = - (aFileExt.FindCharInSet(KNOWN_PATH_SEPARATORS FILE_ILLEGAL_CHARACTERS) != - kNotFound) || - ShouldForceExtension(aFileExt); - - if (replaceExtension) { - int32_t pos = mSuggestedFileName.RFindChar('.'); - if (pos != kNotFound) { - mSuggestedFileName = - Substring(mSuggestedFileName, 0, pos) + mTempFileExtension; - } else { - mSuggestedFileName.Append(mTempFileExtension); - } - } - - /* - * Ensure we don't double-append the file extension if it matches: - */ - if (replaceExtension || - aFileExt.Equals(mTempFileExtension, nsCaseInsensitiveStringComparator)) { - // Matches -> mTempFileExtension can be empty - mTempFileExtension.Truncate(); - } -} - void nsExternalAppHandler::DidDivertRequest(nsIRequest* request) { MOZ_ASSERT(XRE_IsContentProcess(), "in child process"); // Remove our request from the child loadGroup @@ -2793,7 +2525,7 @@ NS_IMETHODIMP nsExternalAppHandler::PromptForSaveDestination() { } if (mSuggestedFileName.IsEmpty()) { - RequestSaveDestination(mTempLeafName, mTempFileExtension); + RequestSaveDestination(mTempLeafName, mFileExtension); } else { nsAutoString fileExt; int32_t pos = mSuggestedFileName.RFindChar('.'); @@ -2801,7 +2533,7 @@ NS_IMETHODIMP nsExternalAppHandler::PromptForSaveDestination() { mSuggestedFileName.Right(fileExt, mSuggestedFileName.Length() - pos); } if (fileExt.IsEmpty()) { - fileExt = mTempFileExtension; + fileExt = mFileExtension; } RequestSaveDestination(mSuggestedFileName, fileExt); @@ -2929,7 +2661,13 @@ NS_IMETHODIMP nsExternalAppHandler::SetDownloadToLaunch( } #ifdef XP_WIN - fileToUse->Append(mSuggestedFileName + mTempFileExtension); + // Ensure we don't double-append the file extension if it matches: + if (StringEndsWith(mSuggestedFileName, mFileExtension, + nsCaseInsensitiveStringComparator)) { + fileToUse->Append(mSuggestedFileName); + } else { + fileToUse->Append(mSuggestedFileName + mFileExtension); + } #else fileToUse->Append(mSuggestedFileName); #endif @@ -3484,3 +3222,321 @@ nsresult nsExternalHelperAppService::GetMIMEInfoFromOS( *aFound = false; return NS_ERROR_NOT_IMPLEMENTED; } + +bool nsExternalHelperAppService::GetFileNameFromChannel(nsIChannel* aChannel, + nsAString& aFileName, + nsIURI** aURI) { + if (!aChannel) { + return false; + } + + aChannel->GetURI(aURI); + nsCOMPtr url = do_QueryInterface(*aURI); + + // Check if we have a POST request, in which case we don't want to use + // the url's extension + bool allowURLExt = !net::ChannelIsPost(aChannel); + + // Check if we had a query string - we don't want to check the URL + // extension if a query is present in the URI + // If we already know we don't want to check the URL extension, don't + // bother checking the query + if (url && allowURLExt) { + nsAutoCString query; + + // We only care about the query for HTTP and HTTPS URLs + if (url->SchemeIs("http") || url->SchemeIs("https")) { + url->GetQuery(query); + } + + // Only get the extension if the query is empty; if it isn't, then the + // extension likely belongs to a cgi script and isn't helpful + allowURLExt = query.IsEmpty(); + } + + aChannel->GetContentDispositionFilename(aFileName); + + return allowURLExt; +} + +NS_IMETHODIMP +nsExternalHelperAppService::GetValidFileName(nsIChannel* aChannel, + const nsACString& aType, + nsIURI* aOriginalURI, + uint32_t aFlags, + nsAString& aOutFileName) { + nsCOMPtr uri; + bool allowURLExtension = + GetFileNameFromChannel(aChannel, aOutFileName, getter_AddRefs(uri)); + + nsCOMPtr mimeInfo = ValidateFileNameForSaving( + aOutFileName, aType, uri, aOriginalURI, aFlags, allowURLExtension); + return NS_OK; +} + +NS_IMETHODIMP +nsExternalHelperAppService::ValidateFileNameForSaving( + const nsAString& aFileName, const nsACString& aType, uint32_t aFlags, + nsAString& aOutFileName) { + nsAutoString fileName(aFileName); + + // Just sanitize the filename only. + if (aFlags & VALIDATE_SANITIZE_ONLY) { + nsAutoString extension; + int32_t dotidx = fileName.RFind("."); + if (dotidx != -1) { + extension = Substring(fileName, dotidx + 1); + } + + SanitizeFileName(fileName, NS_ConvertUTF16toUTF8(extension), aFlags); + } else { + nsCOMPtr mimeInfo = ValidateFileNameForSaving( + fileName, aType, nullptr, nullptr, aFlags, true); + } + + aOutFileName = fileName; + return NS_OK; +} + +already_AddRefed +nsExternalHelperAppService::ValidateFileNameForSaving( + nsAString& aFileName, const nsACString& aMimeType, nsIURI* aURI, + nsIURI* aOriginalURI, uint32_t aFlags, bool aAllowURLExtension) { + nsAutoString fileName(aFileName); + nsAutoCString extension; + nsCOMPtr mimeInfo; + + // We get the mime service here even though we're the default implementation + // of it, so it's possible to override only the mime service and not need to + // reimplement the whole external helper app service itself. + nsCOMPtr mimeService = do_GetService("@mozilla.org/mime;1"); + if (mimeService) { + if (fileName.IsEmpty()) { + nsCOMPtr url = do_QueryInterface(aURI); + // Try to extract the file name from the url and use that as a first + // pass as the leaf name of our temp file... + if (url) { + nsAutoCString leafName; + url->GetFileName(leafName); + if (!leafName.IsEmpty()) { + if (NS_SUCCEEDED(UnescapeFragment(leafName, url, fileName))) { + CopyUTF8toUTF16(leafName, aFileName); // use escaped name + } + } + + // Only get the extension from the URL if allowed. + if (aAllowURLExtension) { + url->GetFileExtension(extension); + } + } + } else { + // Determine the current extension for the filename. + int32_t dotidx = fileName.RFind("."); + if (dotidx != -1) { + CopyUTF16toUTF8(Substring(fileName, dotidx + 1), extension); + } + } + + if (aFlags & VALIDATE_GUESS_FROM_EXTENSION) { + nsAutoCString mimeType; + if (!extension.IsEmpty()) { + mimeService->GetFromTypeAndExtension(EmptyCString(), extension, + getter_AddRefs(mimeInfo)); + if (mimeInfo) { + mimeInfo->GetMIMEType(mimeType); + } + } + + if (mimeType.IsEmpty()) { + // Extension lookup gave us no useful match, so use octet-stream + // instead. + mimeService->GetFromTypeAndExtension( + nsLiteralCString(APPLICATION_OCTET_STREAM), extension, + getter_AddRefs(mimeInfo)); + } + } else if (!aMimeType.IsEmpty()) { + // If this is a binary type, include the extension as a hint to get + // the mime info. For other types, the mime type itself should be + // sufficient. + // The special case for application/ogg is because that type could + // actually be used for a video which can better be determined by the + // extension. This is tested by browser_save_video.js. + bool useExtension = aMimeType.EqualsLiteral(APPLICATION_OCTET_STREAM) || + aMimeType.EqualsLiteral(BINARY_OCTET_STREAM) || + aMimeType.EqualsLiteral("application/x-msdownload") || + aMimeType.EqualsLiteral(APPLICATION_OGG); + mimeService->GetFromTypeAndExtension( + aMimeType, useExtension ? extension : EmptyCString(), + getter_AddRefs(mimeInfo)); + if (mimeInfo) { + // But if no primary extension was returned, this mime type is probably + // an unknown type. Look it up again but this time supply the extension. + nsAutoCString primaryExtension; + mimeInfo->GetPrimaryExtension(primaryExtension); + if (primaryExtension.IsEmpty()) { + mimeService->GetFromTypeAndExtension(aMimeType, extension, + getter_AddRefs(mimeInfo)); + } + } + } + } + + // Windows ignores terminating dots. So we have to as well, so + // that our security checks do "the right thing" + fileName.Trim(".", false); + + if (mimeService) { + bool isValidExtension; + if (extension.IsEmpty() || + NS_FAILED(mimeInfo->ExtensionExists(extension, &isValidExtension)) || + !isValidExtension) { + nsAutoCString originalExtension(extension); + // If an original url was supplied, see if it has a valid extension. + bool useOldExtension = false; + if (aOriginalURI) { + nsCOMPtr originalURL(do_QueryInterface(aOriginalURI)); + if (originalURL) { + originalURL->GetFileExtension(extension); + if (!extension.IsEmpty()) { + mimeInfo->ExtensionExists(extension, &useOldExtension); + } + } + } + + if (!useOldExtension) { + // If the filename doesn't have a valid extension, or we don't know the + // extension, try to use the primary extension for the type. If we don't + // know the primary extension for the type, just continue with the + // existing extension, or leave the filename with no extension. + mimeInfo->GetPrimaryExtension(extension); + } + + ModifyExtensionType modify = + ShouldModifyExtension(mimeInfo, originalExtension); + if (modify == ModifyExtension_Replace) { + int32_t dotidx = fileName.RFind("."); + if (dotidx != -1) { + // Remove the existing extension and replace it. + fileName.Truncate(dotidx); + } + } + + // Otherwise, just append the proper extension to the end of the + // filename, adding to the invalid extension that might already be there. + if (modify != ModifyExtension_Ignore && !extension.IsEmpty()) { + fileName.AppendLiteral("."); + fileName.Append(NS_ConvertUTF8toUTF16(extension)); + } + } + } + + // Make the filename safe for the filesystem + SanitizeFileName(fileName, extension, aFlags); + + aFileName = fileName; + return mimeInfo.forget(); +} + +void nsExternalHelperAppService::SanitizeFileName(nsAString& aFileName, + const nsACString& aExtension, + uint32_t aFlags) { + nsAutoString fileName(aFileName); + + fileName.ReplaceChar(KNOWN_PATH_SEPARATORS, '_'); + fileName.ReplaceChar(FILE_ILLEGAL_CHARACTERS, ' '); + fileName.StripChar(char16_t(0)); + + // Remove unsafe bidi characters which might have spoofing implications (bug + // 511521). + const char16_t unsafeBidiCharacters[] = { + char16_t(0x061c), // Arabic Letter Mark + char16_t(0x200e), // Left-to-Right Mark + char16_t(0x200f), // Right-to-Left Mark + char16_t(0x202a), // Left-to-Right Embedding + char16_t(0x202b), // Right-to-Left Embedding + char16_t(0x202c), // Pop Directional Formatting + char16_t(0x202d), // Left-to-Right Override + char16_t(0x202e), // Right-to-Left Override + char16_t(0x2066), // Left-to-Right Isolate + char16_t(0x2067), // Right-to-Left Isolate + char16_t(0x2068), // First Strong Isolate + char16_t(0x2069), // Pop Directional Isolate + char16_t(0)}; + fileName.ReplaceChar(unsafeBidiCharacters, '_'); + + // Trim whitespace, periods and vowel separators from the beginning and + // end of the filename. Periods are removed to avoid creating hidden files. + fileName.Trim(" .\f\n\r\t\v", true, true); + + // Collapse duplicate whitespace. + if (!(aFlags & VALIDATE_DONT_COLLAPSE_WHITESPACE)) { + fileName.CompressWhitespace(); + } + + // If the filename is too long, truncate it, but preserve the desired + // extension. + if (!(aFlags & VALIDATE_DONT_TRUNCATE) && + fileName.Length() > kDefaultMaxFileNameLength) { + // This is extremely unlikely, but if the extension is larger than the + // maximum size, just get rid of it. + if (aExtension.Length() >= kDefaultMaxFileNameLength) { + fileName.Truncate(kDefaultMaxFileNameLength - 1); + } else { + fileName.Truncate(kDefaultMaxFileNameLength - aExtension.Length() - 1); + if (!fileName.IsEmpty()) { + if (fileName.Last() != '.') { + fileName.AppendLiteral("."); + } + + fileName.Append(NS_ConvertUTF8toUTF16(aExtension)); + } + } + } + + aFileName = fileName; +} + +nsExternalHelperAppService::ModifyExtensionType +nsExternalHelperAppService::ShouldModifyExtension(nsIMIMEInfo* aMimeInfo, + const nsCString& aFileExt) { + nsAutoCString MIMEType; + if (!aMimeInfo || NS_FAILED(aMimeInfo->GetMIMEType(MIMEType))) { + return ModifyExtension_Append; + } + + // Determine whether the extensions should be appended or replaced depending + // on the content type. + bool canForce = StringBeginsWith(MIMEType, "image/"_ns) || + StringBeginsWith(MIMEType, "audio/"_ns) || + StringBeginsWith(MIMEType, "video/"_ns); + + if (!canForce) { + for (const char* mime : forcedExtensionMimetypes) { + if (MIMEType.Equals(mime)) { + if (!StaticPrefs::browser_download_sanitize_non_media_extensions()) { + return ModifyExtension_Ignore; + } + canForce = true; + break; + } + } + + if (!canForce) { + return ModifyExtension_Append; + } + } + + // If we get here, we know for sure the mimetype allows us to modify the + // existing extension, if it's wrong. Return whether we should replace it + // or append it. + bool knownExtension = false; + // Note that aFileExt is either empty or consists of an extension + // excluding the dot. + if (aFileExt.IsEmpty() || + (NS_SUCCEEDED(aMimeInfo->ExtensionExists(aFileExt, &knownExtension)) && + !knownExtension)) { + return ModifyExtension_Replace; + } + + return ModifyExtension_Append; +} diff --git a/uriloader/exthandler/nsExternalHelperAppService.h b/uriloader/exthandler/nsExternalHelperAppService.h index 0d4b2bde66c7..f8832bbde404 100644 --- a/uriloader/exthandler/nsExternalHelperAppService.h +++ b/uriloader/exthandler/nsExternalHelperAppService.h @@ -199,6 +199,32 @@ class nsExternalHelperAppService : public nsIExternalHelperAppService, */ void ExpungeTemporaryPrivateFiles(); + bool GetFileNameFromChannel(nsIChannel* aChannel, nsAString& aFileName, + nsIURI** aURI); + + // Internal version of the method from nsIMIMEService. + already_AddRefed ValidateFileNameForSaving( + nsAString& aFileName, const nsACString& aMimeType, nsIURI* aURI, + nsIURI* aOriginalURI, uint32_t aFlags, bool aAllowURLExtension); + + void SanitizeFileName(nsAString& aFileName, const nsACString& aExtension, + uint32_t aFlags); + + /** + * Helper routine that checks how we should modify an extension + * for this file. + */ + enum ModifyExtensionType { + // Replace an invalid extension with the preferred one. + ModifyExtension_Replace = 0, + // Append the preferred extension after any existing one. + ModifyExtension_Append = 1, + // Don't modify the extension. + ModifyExtension_Ignore = 2 + }; + ModifyExtensionType ShouldModifyExtension(nsIMIMEInfo* aMimeInfo, + const nsCString& aFileExt); + /** * Array for the files that should be deleted */ @@ -251,15 +277,15 @@ class nsExternalAppHandler final : public nsIStreamListener, * in which case dialogs will be parented to * aContentContext. * @param mExtProtSvc nsExternalHelperAppService on creation - * @param aFileName The filename to use + * @param aSuggestedFileName The filename to use * @param aReason A constant from nsIHelperAppLauncherDialog * indicating why the request is handled by a helper app. */ - nsExternalAppHandler(nsIMIMEInfo* aMIMEInfo, const nsACString& aFileExtension, + nsExternalAppHandler(nsIMIMEInfo* aMIMEInfo, const nsAString& aFileExtension, mozilla::dom::BrowsingContext* aBrowsingContext, nsIInterfaceRequestor* aWindowContext, nsExternalHelperAppService* aExtProtSvc, - const nsAString& aFilename, uint32_t aReason, + const nsAString& aSuggestedFileName, uint32_t aReason, bool aForceSave); /** @@ -284,7 +310,7 @@ class nsExternalAppHandler final : public nsIStreamListener, nsCOMPtr mTempFile; nsCOMPtr mSourceUrl; - nsString mTempFileExtension; + nsString mFileExtension; nsString mTempLeafName; /** @@ -473,12 +499,6 @@ class nsExternalAppHandler final : public nsIStreamListener, */ bool GetNeverAskFlagFromPref(const char* prefName, const char* aContentType); - /** - * Helper routine that checks whether we should enforce an extension - * for this file. - */ - bool ShouldForceExtension(const nsString& aFileExt); - /** * Helper routine to ensure that mSuggestedFileName ends in the correct * extension, in case the original extension contains invalid characters @@ -486,7 +506,7 @@ class nsExternalAppHandler final : public nsIStreamListener, * extension (image/, video/, and audio/ based mimetypes, and a few specific * document types). * - * It also ensure that mTempFileExtension only contains an extension + * It also ensure that mFileExtension only contains an extension * when it is different from mSuggestedFileName's extension. */ void EnsureCorrectExtension(const nsString& aFileExt);