Security: Replace Unicode order overrides in attachment names

As a user, when I receive a file attachment, I want to have confidence that the
filename I see in the Signal Desktop app is the same as it will be on disk.

To prevent user confusion when receiving files with Unicode order override
characters, e.g. `test<LTRO>fig.exe` appearing as `testexe.gif`, we replace all
occurrences of order overrides (`U+202D` and `U+202E`) with `U+FFFD`.

**Changes**
- [x] Bump `Attachment` `schemaVersion` to 2.
- [x] Replace all Unicode order overrides in `attachment.filename`:
      `Attachment.replaceUnicodeOrderOverrides`.
- [x] Add tests for existing `Attachment.upgradeSchema`
- [x] Add tests for existing `Attachment.withSchemaVersion`
- [x] Add tests for `Attachment.replaceUnicodeOrderOverrides` positives.
- [x] Add `testcheck` generative property-based testing library
      (based on QuickCheck) to ensure valid filenames are preserved.

---

commit 855bdbc7e647e44f73b9e1f5e6d64f734c61169a
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Thu Feb 22 13:02:01 2018 -0500

    Log error stack in case of error

commit 6e053ed66aee136f186568fa88aacd4814b2ab07
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Thu Feb 22 12:30:28 2018 -0500

    Improve `upgradeStep` error handling

commit 8c226a2523b701cb578b2137832c3eaf3475bb2b
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Thu Feb 22 12:30:08 2018 -0500

    Check for expected version before upgrade

    Prevents out of order upgrade steps.

commit 28b0675591e782169128f75429b7bab2a22307fa
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Thu Feb 22 12:29:52 2018 -0500

    Reject invalid attachments

commit 41f4f457dae9416dae66dc2fa2079483d1f127a9
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Thu Feb 22 12:29:36 2018 -0500

    Fix upgrade pipeline order

commit 3935629e91c49b8d96c1e02bd37b1b31d1180720
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Thu Feb 22 12:28:25 2018 -0500

    Avoid `_.isPlainObject`

    Attachments are deserialized from a protocol buffer and can have a
    non-plain-object constructor.

commit 39f6e7f622ff4885e2ccafa354e0edb5864c55d8
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Thu Feb 22 12:19:07 2018 -0500

    Define basic attachment validity

commit adcf7e3243cd90866cc35990c558ff7829019037
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Thu Feb 22 12:18:54 2018 -0500

    Add tests for attachment upgrade pipeline

commit 82fc4644d7e654eea9f348518b086497be2b0cb4
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Wed Feb 21 12:20:24 2018 -0500

    Favor `async` / `await` over `then`

commit 8fe49e3c40e78ced0b8f2eb0b678f4bae842855d
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Wed Feb 21 12:19:59 2018 -0500

    Add `eslint-more` plugin

    This will enable us to disallow `then` in favor of `async` / `await`.

commit 020beefb25f508ae96cf3fc099599fbbca98802b
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Wed Feb 21 11:31:49 2018 -0500

    Remove unnecessary `async` modifiers

commit 177090c5f5ad9836f0ca0a5c2f298779519e3692
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Wed Feb 21 11:30:55 2018 -0500

    Document `operator-linebreak` ESLint rule

commit 25622b7c59291cb672ae057c47e7327a564cca40
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Wed Feb 21 11:14:15 2018 -0500

    Prefix internal function with `_`

commit 6aa3cf5098df71e9b710064739ec49d74f81b7bf
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Fri Feb 16 19:00:07 2018 -0500

    Replace all Unicode order override occurrences

commit fd6e23b0a519bce3c12c5b9ac676bcd198034fed
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Fri Feb 16 17:48:41 2018 -0500

    Whitelist `testcheck` `check` and `gen` globals

commit 400bae9fac5078821813bc0ca17a5d7a72900161
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Fri Feb 16 17:46:57 2018 -0500

    🎨 Fix lint errors

commit da53d3960aa7aa36b7cc1fcff414c9e929c0d9fc
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Fri Feb 16 17:42:42 2018 -0500

    Add tests for `Attachment.withSchemaVersion`

commit ec203444239d9e3c443ba88cab7ef4672151072d
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Fri Feb 16 17:42:17 2018 -0500

    Add test for `Attachment.upgradeSchema`

commit 4540d5bdf7a4279f49d2e4c6ee03f47b93df46bf
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Fri Feb 16 17:05:29 2018 -0500

    Rename `setSchemaVersion` --> `withSchemaVersion`

    Put the schema version first for better readability.

commit e379cf919feda31d1fa96d406c30fd38e159a11d
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Fri Feb 16 17:03:22 2018 -0500

    Add filename sanitization to upgrade pipeline

commit 1e344a0d15926fc3e17be20cd90bfa882b65f337
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Fri Feb 16 17:01:55 2018 -0500

    Test that we preserve non-suspicious filenames

commit a2452bfc98f93f82bed48b438757af2e66a6af82
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Fri Feb 16 17:00:56 2018 -0500

    Add `testcheck` dependency

    Allows for generative property-based testing similar to Haskell’s QuickCheck.
    See: https://medium.com/javascript-inside/f91432247c27

commit ceb5bfd2484a77689fdb8e9edd18d4a7b093a486
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Fri Feb 16 16:15:33 2018 -0500

    Replace Unicode order override characters

    Prevents users from being tricked into clicking a file named `testexe.fig`
    that appears as `testexe.gif` due to a Unicode order override character.

    See:
    - http://unicode.org/reports/tr36/#Bidirectional_Text_Spoofing
    - https://krebsonsecurity.com/2011/09/right-to-left-override-aids-email-attacks/

commit bc605afb1c6af3a5ebc31a4c1523ff170eb96ffe
Author: Daniel Gasienica <daniel@gasienica.ch>
Date:   Fri Feb 16 16:12:29 2018 -0500

    Remove `CURRENT_PROCESS_VERSION`

    Reintroduce this whenever we need it. We currently only deal with schema version
    numbers within this module.
This commit is contained in:
Daniel Gasienica 2018-02-22 13:21:53 -05:00
parent 06a16baaa5
commit a1ac810343
10 changed files with 409 additions and 27 deletions

6
test/modules/.eslintrc Normal file
View file

@ -0,0 +1,6 @@
{
"globals": {
"check": true,
"gen": true
}
}

View file

@ -0,0 +1,246 @@
require('mocha-testcheck').install();
const { assert } = require('chai');
const Attachment = require('../../../js/modules/types/attachment');
describe('Attachment', () => {
describe('upgradeSchema', () => {
it('should upgrade an unversioned attachment to the latest version', async () => {
const input = {
contentType: 'application/json',
data: null,
fileName: 'test\u202Dfig.exe',
size: 1111,
};
const expected = {
contentType: 'application/json',
data: null,
fileName: 'test\uFFFDfig.exe',
size: 1111,
schemaVersion: Attachment.CURRENT_SCHEMA_VERSION,
};
const actual = await Attachment.upgradeSchema(input);
assert.deepEqual(actual, expected);
});
context('with multiple upgrade steps', () => {
it('should return last valid attachment when any upgrade step fails', async () => {
const input = {
contentType: 'application/json',
data: null,
fileName: 'test\u202Dfig.exe',
size: 1111,
};
const expected = {
contentType: 'application/json',
data: null,
fileName: 'test\u202Dfig.exe',
size: 1111,
schemaVersion: 1,
hasUpgradedToVersion1: true,
};
const v1 = async attachment =>
Object.assign({}, attachment, { hasUpgradedToVersion1: true });
const v2 = async () => {
throw new Error('boom');
};
const v3 = async attachment =>
Object.assign({}, attachment, { hasUpgradedToVersion3: true });
const toVersion1 = Attachment.withSchemaVersion(1, v1);
const toVersion2 = Attachment.withSchemaVersion(2, v2);
const toVersion3 = Attachment.withSchemaVersion(3, v3);
const upgradeSchema = async attachment =>
toVersion3(await toVersion2(await toVersion1(attachment)));
const actual = await upgradeSchema(input);
assert.deepEqual(actual, expected);
});
it('should skip out-of-order upgrade steps', async () => {
const input = {
contentType: 'application/json',
data: null,
fileName: 'test\u202Dfig.exe',
size: 1111,
};
const expected = {
contentType: 'application/json',
data: null,
fileName: 'test\u202Dfig.exe',
size: 1111,
schemaVersion: 2,
hasUpgradedToVersion1: true,
hasUpgradedToVersion2: true,
};
const v1 = async attachment =>
Object.assign({}, attachment, { hasUpgradedToVersion1: true });
const v2 = async attachment =>
Object.assign({}, attachment, { hasUpgradedToVersion2: true });
const v3 = async attachment =>
Object.assign({}, attachment, { hasUpgradedToVersion3: true });
const toVersion1 = Attachment.withSchemaVersion(1, v1);
const toVersion2 = Attachment.withSchemaVersion(2, v2);
const toVersion3 = Attachment.withSchemaVersion(3, v3);
// NOTE: We upgrade to 3 before 2, i.e. the pipeline should abort:
const upgradeSchema = async attachment =>
toVersion2(await toVersion3(await toVersion1(attachment)));
const actual = await upgradeSchema(input);
assert.deepEqual(actual, expected);
});
});
});
describe('withSchemaVersion', () => {
it('should require a version number', () => {
const toVersionX = () => {};
assert.throws(
() => Attachment.withSchemaVersion(toVersionX, 2),
'`schemaVersion` must be a number'
);
});
it('should require an upgrade function', () => {
assert.throws(
() => Attachment.withSchemaVersion(2, 3),
'`upgrade` must be a function'
);
});
it('should skip upgrading if attachment has already been upgraded', async () => {
const upgrade = async attachment =>
Object.assign({}, attachment, { foo: true });
const upgradeWithVersion = Attachment.withSchemaVersion(3, upgrade);
const input = {
contentType: 'image/gif',
data: null,
fileName: 'foo.gif',
size: 1111,
schemaVersion: 4,
};
const actual = await upgradeWithVersion(input);
assert.deepEqual(actual, input);
});
it('should return original attachment if upgrade function throws', async () => {
const upgrade = async () => {
throw new Error('boom!');
};
const upgradeWithVersion = Attachment.withSchemaVersion(3, upgrade);
const input = {
contentType: 'image/gif',
data: null,
fileName: 'foo.gif',
size: 1111,
};
const actual = await upgradeWithVersion(input);
assert.deepEqual(actual, input);
});
it('should return original attachment if upgrade function returns null', async () => {
const upgrade = async () => null;
const upgradeWithVersion = Attachment.withSchemaVersion(3, upgrade);
const input = {
contentType: 'image/gif',
data: null,
fileName: 'foo.gif',
size: 1111,
};
const actual = await upgradeWithVersion(input);
assert.deepEqual(actual, input);
});
});
describe('replaceUnicodeOrderOverrides', () => {
it('should sanitize left-to-right order override character', async () => {
const input = {
contentType: 'image/jpeg',
data: null,
fileName: 'test\u202Dfig.exe',
size: 1111,
schemaVersion: 1,
};
const expected = {
contentType: 'image/jpeg',
data: null,
fileName: 'test\uFFFDfig.exe',
size: 1111,
schemaVersion: 1,
};
const actual = await Attachment.replaceUnicodeOrderOverrides(input);
assert.deepEqual(actual, expected);
});
it('should sanitize right-to-left order override character', async () => {
const input = {
contentType: 'image/jpeg',
data: null,
fileName: 'test\u202Efig.exe',
size: 1111,
schemaVersion: 1,
};
const expected = {
contentType: 'image/jpeg',
data: null,
fileName: 'test\uFFFDfig.exe',
size: 1111,
schemaVersion: 1,
};
const actual = await Attachment.replaceUnicodeOrderOverrides(input);
assert.deepEqual(actual, expected);
});
it('should sanitize multiple override characters', async () => {
const input = {
contentType: 'image/jpeg',
data: null,
fileName: 'test\u202e\u202dlol\u202efig.exe',
size: 1111,
schemaVersion: 1,
};
const expected = {
contentType: 'image/jpeg',
data: null,
fileName: 'test\uFFFD\uFFFDlol\uFFFDfig.exe',
size: 1111,
schemaVersion: 1,
};
const actual = await Attachment.replaceUnicodeOrderOverrides(input);
assert.deepEqual(actual, expected);
});
const hasNoUnicodeOrderOverrides = value =>
!value.includes('\u202D') && !value.includes('\u202E');
check.it(
'should ignore non-order-override characters',
gen.string.suchThat(hasNoUnicodeOrderOverrides),
(fileName) => {
const input = {
contentType: 'image/jpeg',
data: null,
fileName,
size: 1111,
schemaVersion: 1,
};
const actual = Attachment._replaceUnicodeOrderOverridesSync(input);
assert.deepEqual(actual, input);
}
);
});
});