diff --git a/app/attachments.js b/app/attachments.js index b5713fa1c..f7f91230d 100644 --- a/app/attachments.js +++ b/app/attachments.js @@ -12,6 +12,10 @@ const normalizePath = require('normalize-path'); const sanitizeFilename = require('sanitize-filename'); const getGuid = require('uuid/v4'); const { isPathInside } = require('../ts/util/isPathInside'); +const { isWindows } = require('../ts/OS'); +const { + writeWindowsZoneIdentifier, +} = require('../ts/util/windowsZoneIdentifier'); let xattr; try { @@ -229,6 +233,13 @@ async function writeWithAttributes(target, data) { const attrValue = `${type};${timestamp};${appName};${guid}`; await xattr.set(target, 'com.apple.quarantine', attrValue); + } else if (isWindows()) { + // This operation may fail (see the function's comments), which is not a show-stopper. + try { + await writeWindowsZoneIdentifier(target); + } catch (err) { + console.warn('Failed to write Windows Zone.Identifier file; continuing'); + } } } diff --git a/ts/test/helpers.ts b/ts/test/helpers.ts new file mode 100644 index 000000000..879c12262 --- /dev/null +++ b/ts/test/helpers.ts @@ -0,0 +1,14 @@ +import { assert } from 'chai'; + +export async function assertRejects(fn: () => Promise): Promise { + let err: unknown; + try { + await fn(); + } catch (e) { + err = e; + } + assert( + err instanceof Error, + 'Expected promise to reject with an Error, but it resolved' + ); +} diff --git a/ts/test/util/windowsZoneIdentifier_test.ts b/ts/test/util/windowsZoneIdentifier_test.ts new file mode 100644 index 000000000..16e51fd78 --- /dev/null +++ b/ts/test/util/windowsZoneIdentifier_test.ts @@ -0,0 +1,64 @@ +import { assert } from 'chai'; +import * as path from 'path'; +import * as os from 'os'; +import * as fs from 'fs'; +import * as fse from 'fs-extra'; +import * as Sinon from 'sinon'; +import { assertRejects } from '../helpers'; + +import { writeWindowsZoneIdentifier } from '../../util/windowsZoneIdentifier'; + +describe('writeWindowsZoneIdentifier', () => { + before(function thisNeeded() { + if (process.platform !== 'win32') { + this.skip(); + } + }); + + beforeEach(async function thisNeeded() { + this.sandbox = Sinon.createSandbox(); + this.tmpdir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'signal-test-') + ); + }); + + afterEach(async function thisNeeded() { + this.sandbox.restore(); + await fse.remove(this.tmpdir); + }); + + it('writes zone transfer ID 3 (internet) to the Zone.Identifier file', async function thisNeeded() { + const file = path.join(this.tmpdir, 'file.txt'); + await fse.outputFile(file, 'hello'); + + await writeWindowsZoneIdentifier(file); + + assert.strictEqual( + await fs.promises.readFile(`${file}:Zone.Identifier`, 'utf8'), + '[ZoneTransfer]\r\nZoneId=3' + ); + }); + + it('fails if there is an existing Zone.Identifier file', async function thisNeeded() { + const file = path.join(this.tmpdir, 'file.txt'); + await fse.outputFile(file, 'hello'); + await fs.promises.writeFile(`${file}:Zone.Identifier`, '# already here'); + + await assertRejects(() => writeWindowsZoneIdentifier(file)); + }); + + it('fails if the original file does not exist', async function thisNeeded() { + const file = path.join(this.tmpdir, 'file-never-created.txt'); + + await assertRejects(() => writeWindowsZoneIdentifier(file)); + }); + + it('fails if not on Windows', async function thisNeeded() { + this.sandbox.stub(process, 'platform').get(() => 'darwin'); + + const file = path.join(this.tmpdir, 'file.txt'); + await fse.outputFile(file, 'hello'); + + await assertRejects(() => writeWindowsZoneIdentifier(file)); + }); +}); diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 96694bbab..2e556825b 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -13151,6 +13151,24 @@ "reasonCategory": "falseMatch", "updated": "2020-02-07T19:52:28.522Z" }, + { + "rule": "jQuery-before(", + "path": "ts/test/util/windowsZoneIdentifier_test.js", + "line": " before(function thisNeeded() {", + "lineNumber": 19, + "reasonCategory": "testCode", + "updated": "2020-09-02T18:59:59.432Z", + "reasonDetail": "This is test code (and isn't jQuery code)." + }, + { + "rule": "jQuery-before(", + "path": "ts/test/util/windowsZoneIdentifier_test.ts", + "line": " before(function thisNeeded() {", + "lineNumber": 12, + "reasonCategory": "testCode", + "updated": "2020-09-02T18:59:59.432Z", + "reasonDetail": "This is test code (and isn't jQuery code)." + }, { "rule": "jQuery-append(", "path": "ts/textsecure/ContactsParser.js", diff --git a/ts/util/windowsZoneIdentifier.ts b/ts/util/windowsZoneIdentifier.ts new file mode 100644 index 000000000..9c48594a8 --- /dev/null +++ b/ts/util/windowsZoneIdentifier.ts @@ -0,0 +1,45 @@ +import * as fs from 'fs'; +import { isWindows } from '../OS'; + +const ZONE_IDENTIFIER_CONTENTS = Buffer.from('[ZoneTransfer]\r\nZoneId=3'); + +/** + * Internet Explorer introduced the concept of "Security Zones". For our purposes, we + * just need to set the security zone to the "Internet" zone, which Windows will use to + * offer some protections. This is customizable by the user (or, more likely, by IT). + * + * To do this, we write the "Zone.Identifier" for the NTFS alternative data stream. + * + * This can fail in a bunch of sitations: + * + * - The OS is not Windows. + * - The filesystem is not NTFS. + * - Writing the metadata file fails for some reason (permissions, for example). + * - The metadata file already exists. (We could choose to overwrite it.) + * - The original file is deleted between the time that we check for its existence and + * when we write the metadata. This is a rare race condition, but is possible. + * + * Consumers of this module should probably tolerate failures. + */ +export async function writeWindowsZoneIdentifier( + filePath: string +): Promise { + if (!isWindows()) { + throw new Error('writeWindowsZoneIdentifier should only run on Windows'); + } + + // tslint:disable-next-line non-literal-fs-path + if (!fs.existsSync(filePath)) { + throw new Error( + 'writeWindowsZoneIdentifier could not find the original file' + ); + } + + await fs.promises.writeFile( + `${filePath}:Zone.Identifier`, + ZONE_IDENTIFIER_CONTENTS, + { + flag: 'wx', + } + ); +}