diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 695183e5d78..2fb4f6f6bff 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -546,6 +546,11 @@ "message": "Unsupported file type", "description": "Displayed for outgoing unsupported attachment" }, + "dangerousFileType": { + "message": "Attachment type not allowed for security reasons", + "description": + "Shown in toast when user attempts to send .exe file, for example" + }, "fileSizeWarning": { "message": "Sorry, the selected file exceeds message size restrictions." }, diff --git a/images/error-filled.svg b/images/error-filled.svg new file mode 100644 index 00000000000..dc013dd2489 --- /dev/null +++ b/images/error-filled.svg @@ -0,0 +1,22 @@ + + + + Error/error-filled-16 + Created with Sketch. + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/js/models/messages.js b/js/models/messages.js index 2be343b0c9c..a1e84813700 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -439,10 +439,11 @@ message: this, }), - onDownload: () => + onDownload: isDangerous => this.trigger('download', { attachment: firstAttachment, message: this, + isDangerous, }), }; }, diff --git a/js/modules/types/attachment.js b/js/modules/types/attachment.js index 89df04d8b89..272c0d43956 100644 --- a/js/modules/types/attachment.js +++ b/js/modules/types/attachment.js @@ -108,6 +108,29 @@ exports._replaceUnicodeOrderOverridesSync = attachment => { exports.replaceUnicodeOrderOverrides = async attachment => exports._replaceUnicodeOrderOverridesSync(attachment); +// \u202A-\u202E is LRE, RLE, PDF, LRO, RLO +// \u2066-\u2069 is LRI, RLI, FSI, PDI +// \u200E is LRM +// \u200F is RLM +// \u061C is ALM +const V2_UNWANTED_UNICODE = /[\u202A-\u202E\u2066-\u2069\u200E\u200F\u061C]/g; + +exports.replaceUnicodeV2 = async attachment => { + if (!is.string(attachment.fileName)) { + return attachment; + } + + const fileName = attachment.fileName.replace( + V2_UNWANTED_UNICODE, + UNICODE_REPLACEMENT_CHARACTER + ); + + return { + ...attachment, + fileName, + }; +}; + exports.removeSchemaVersion = ({ attachment, logger }) => { if (!exports.isValid(attachment)) { logger.error( diff --git a/js/modules/types/message.js b/js/modules/types/message.js index d44925223cb..33faf21635b 100644 --- a/js/modules/types/message.js +++ b/js/modules/types/message.js @@ -44,6 +44,9 @@ const PRIVATE = 'private'; // Version 8 // - Attachments: Capture video/image dimensions and thumbnails, as well as a // full-size screenshot for video. +// Version 9 +// - Attachments: Expand the set of unicode characters we filter out of +// attachment filenames const INITIAL_SCHEMA_VERSION = 0; @@ -270,6 +273,11 @@ const toVersion8 = exports._withSchemaVersion({ upgrade: exports._mapAttachments(Attachment.captureDimensionsAndScreenshot), }); +const toVersion9 = exports._withSchemaVersion({ + schemaVersion: 9, + upgrade: exports._mapAttachments(Attachment.replaceUnicodeV2), +}); + const VERSIONS = [ toVersion0, toVersion1, @@ -280,6 +288,7 @@ const VERSIONS = [ toVersion6, toVersion7, toVersion8, + toVersion9, ]; exports.CURRENT_SCHEMA_VERSION = VERSIONS.length - 1; diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 838b26c4d19..9e2d3216f0b 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -1057,7 +1057,14 @@ } }, - downloadAttachment({ attachment, message }) { + downloadAttachment({ attachment, message, isDangerous }) { + if (isDangerous) { + const toast = new Whisper.DangerousFileTypeToast(); + toast.$el.appendTo(this.$el); + toast.render(); + return; + } + Signal.Types.Attachment.save({ attachment, document, diff --git a/js/views/file_input_view.js b/js/views/file_input_view.js index 4154d14ef9c..0f4218934a6 100644 --- a/js/views/file_input_view.js +++ b/js/views/file_input_view.js @@ -34,6 +34,10 @@ template: i18n('unsupportedFileType'), }); + Whisper.DangerousFileTypeToast = Whisper.ToastView.extend({ + template: i18n('dangerousFileType'), + }); + Whisper.FileInputView = Backbone.View.extend({ tagName: 'span', className: 'file-input', @@ -178,6 +182,14 @@ if (!file) { return; } + const { name } = file; + if (window.Signal.Util.isFileDangerous(name)) { + const toast = new Whisper.DangerousFileTypeToast(); + toast.$el.insertAfter(this.$el); + toast.render(); + + return; + } const contentType = file.type; @@ -297,9 +309,10 @@ getFile(rawFile) { const file = rawFile || this.file || this.$input.prop('files')[0]; - if (file === undefined) { + if (!file) { return Promise.resolve(); } + const attachmentFlags = this.isVoiceNote ? textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE : null; diff --git a/stylesheets/_conversation.scss b/stylesheets/_conversation.scss index 14fa79b1caf..b4d12f7b99b 100644 --- a/stylesheets/_conversation.scss +++ b/stylesheets/_conversation.scss @@ -316,7 +316,7 @@ line-height: 18px; letter-spacing: 0; - background-color: $color-light-60; + background-color: $color-gray-75; color: $color-white; box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(0, 0, 0, 0.08); } diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index cd85a7264f8..f004fa8c9c6 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -345,6 +345,10 @@ padding-top: 4px; } +.module-message__generic-attachment__icon-container { + position: relative; +} + .module-message__generic-attachment__icon { background: url('../images/file-gradient.svg') no-repeat center; height: 44px; @@ -359,6 +363,26 @@ align-items: center; } +.module-message__generic-attachment__icon-dangerous-container { + position: absolute; + + top: -1px; + right: -4px; + + height: 16px; + width: 16px; + + border-radius: 50%; + background-color: $color-white; +} + +.module-message__generic-attachment__icon-dangerous { + height: 16px; + width: 16px; + + @include color-svg('../images/error-filled.svg', $color-core-red); +} + .module-message__generic-attachment__icon__extension { font-size: 10px; line-height: 13px; diff --git a/stylesheets/_theme_dark.scss b/stylesheets/_theme_dark.scss index cc4fe5ffd93..74ddb148f7e 100644 --- a/stylesheets/_theme_dark.scss +++ b/stylesheets/_theme_dark.scss @@ -62,7 +62,7 @@ body.dark-theme { } .toast { - background-color: $color-light-60; + background-color: $color-gray-45; color: $color-white; box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(0, 0, 0, 0.08); diff --git a/test/modules/types/attachment_test.js b/test/modules/types/attachment_test.js index b8af004d444..7c54a33e119 100644 --- a/test/modules/types/attachment_test.js +++ b/test/modules/types/attachment_test.js @@ -83,6 +83,50 @@ describe('Attachment', () => { ); }); + describe('replaceUnicodeV2', () => { + it('should remove all bad characters', async () => { + const input = { + size: 1111, + fileName: + 'file\u202A\u202B\u202C\u202D\u202E\u2066\u2067\u2068\u2069\u200E\u200F\u061C.jpeg', + }; + const expected = { + fileName: + 'file\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD.jpeg', + size: 1111, + }; + + const actual = await Attachment.replaceUnicodeV2(input); + assert.deepEqual(actual, expected); + }); + + it('should should leave normal filename alone', async () => { + const input = { + fileName: 'normal.jpeg', + size: 1111, + }; + const expected = { + fileName: 'normal.jpeg', + size: 1111, + }; + + const actual = await Attachment.replaceUnicodeV2(input); + assert.deepEqual(actual, expected); + }); + + it('should handle missing fileName', async () => { + const input = { + size: 1111, + }; + const expected = { + size: 1111, + }; + + const actual = await Attachment.replaceUnicodeV2(input); + assert.deepEqual(actual, expected); + }); + }); + describe('removeSchemaVersion', () => { it('should remove existing schema version', () => { const input = { diff --git a/ts/components/conversation/Message.md b/ts/components/conversation/Message.md index f2f22e760d1..5f0aa6a2b74 100644 --- a/ts/components/conversation/Message.md +++ b/ts/components/conversation/Message.md @@ -1922,6 +1922,48 @@ Voice notes are not shown any differently from audio attachments. ``` +#### Dangerous file type + +```jsx + +
  • + + console.log('onClickAttachment - isDangerous:', isDangerous) + } + /> +
  • +
  • + + console.log('onClickAttachment - isDangerous:', isDangerous) + } + /> +
  • +
    +``` + ### In a group conversation Note that the author avatar goes away if `collapseMetadata` is set. diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index f9fb54019ff..de625af7008 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -14,6 +14,7 @@ import { ContactName } from './ContactName'; import { Quote, QuotedAttachment } from './Quote'; import { EmbeddedContact } from './EmbeddedContact'; +import { isFileDangerous } from '../../util/isFileDangerous'; import { Contact } from '../../types/Contact'; import { Color, Localizer } from '../../types/Util'; import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu'; @@ -87,7 +88,7 @@ export interface Props { onClickAttachment?: () => void; onReply?: () => void; onRetrySend?: () => void; - onDownload?: () => void; + onDownload?: (isDangerous: boolean) => void; onDelete?: () => void; onShowDetail: () => void; } @@ -363,7 +364,7 @@ export class Message extends React.Component { ); } - // tslint:disable-next-line max-func-body-length cyclomatic-complexity + // tslint:disable-next-line max-func-body-length cyclomatic-complexity jsx-no-lambda react-this-binding-issue public renderAttachment() { const { i18n, @@ -503,6 +504,7 @@ export class Message extends React.Component { } else { const { fileName, fileSize, contentType } = attachment; const extension = getExtension({ contentType, fileName }); + const isDangerous = isFileDangerous(fileName); return (
    { : null )} > -
    - {extension ? ( -
    - {extension} +
    +
    + {extension ? ( +
    + {extension} +
    + ) : null} +
    + {isDangerous ? ( +
    +
    ) : null}
    @@ -734,9 +743,16 @@ export class Message extends React.Component { return null; } + const fileName = attachment && attachment.fileName; + const isDangerous = isFileDangerous(fileName || ''); + const downloadButton = attachment ? (
    { + if (onDownload) { + onDownload(isDangerous); + } + }} role="button" className={classNames( 'module-message__buttons__download', diff --git a/ts/util/index.ts b/ts/util/index.ts index d5af9febc13..b667028a38e 100644 --- a/ts/util/index.ts +++ b/ts/util/index.ts @@ -1,6 +1,13 @@ import * as GoogleChrome from './GoogleChrome'; import { arrayBufferToObjectURL } from './arrayBufferToObjectURL'; +import { isFileDangerous } from './isFileDangerous'; import { missingCaseError } from './missingCaseError'; import { migrateColor } from './migrateColor'; -export { arrayBufferToObjectURL, GoogleChrome, missingCaseError, migrateColor }; +export { + arrayBufferToObjectURL, + GoogleChrome, + isFileDangerous, + migrateColor, + missingCaseError, +}; diff --git a/ts/util/isFileDangerous.ts b/ts/util/isFileDangerous.ts new file mode 100644 index 00000000000..2d39baa1cd7 --- /dev/null +++ b/ts/util/isFileDangerous.ts @@ -0,0 +1,6 @@ +// tslint:disable-next-line max-line-length +const DANGEROUS_FILE_TYPES = /\.(ADE|ADP|APK|BAT|CHM|CMD|COM|CPL|DLL|DMG|EXE|HTA|INS|ISP|JAR|JS|JSE|LIB|LNK|MDE|MSC|MSI|MSP|MST|NSH|PIF|SCR|SCT|SHB|SYS|VB|VBE|VBS|VXD|WSC|WSF|WSH|CAB)$/i; + +export function isFileDangerous(fileName: string): boolean { + return DANGEROUS_FILE_TYPES.test(fileName); +}