Skip to content

Commit 2f257e9

Browse files
author
Ioan Moldovan
authored
#5878 Password protected messages compliance (#5879)
* feat: password protected messages compliance * feat: added link support * feat: target blank * fix: term match * fix: pr reviews
1 parent fc92f9f commit 2f257e9

File tree

9 files changed

+128
-1
lines changed

9 files changed

+128
-1
lines changed

conf/tsconfig.content_scripts.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
"openpgp": ["../node_modules/openpgp/openpgp.d.ts"],
2121
"@openpgp/web-stream-tools": ["../node_modules/@openpgp/web-stream-tools/lib/index.d.ts"],
2222
"squire-rte": ["../node_modules/squire-rte/dist/types/Squire.d.ts"],
23-
"undici-types": ["../node_modules/undici-types/index.d.ts", "COMMENT"]
23+
"undici-types": ["../node_modules/undici-types/index.d.ts", "COMMENT"],
24+
"linkifyHtml": ["../node_modules/linkify-html/dist/linkify-html.es.d.ts", "COMMENT"],
25+
"linkifyjs": ["../node_modules/linkifyjs/dist/linkify.es.d.ts", "COMMENT"]
2426
},
2527
"typeRoots": ["../extension/types", "../extension/js/common/core/types"]
2628
},

extension/chrome/elements/compose-modules/compose-err-module.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import { ViewModule } from '../../../js/common/view-module.js';
1616
import { ComposeView } from '../compose.js';
1717
import { AjaxErrMsgs } from '../../../js/common/api/shared/api-error.js';
1818
import { Lang } from '../../../js/common/lang.js';
19+
import linkifyHtml from 'linkifyHtml';
20+
import { MsgUtil } from '../../../js/common/core/crypto/pgp/msg-util.js';
1921

2022
export class ComposerUserError extends Error {}
2123
class ComposerNotReadyError extends ComposerUserError {}
@@ -160,6 +162,11 @@ export class ComposeErrModule extends ViewModule<ComposeView> {
160162
};
161163

162164
public throwIfEncryptionPasswordInvalidOrDisabled = async ({ subject, pwd }: { subject: string; pwd?: string }) => {
165+
const disallowedPasswordMessageTerms = this.view.clientConfiguration.getDisallowPasswordMessagesForTerms();
166+
const disallowedPasswordMessageErrorText = this.view.clientConfiguration.getDisallowPasswordMessagesErrorText();
167+
if (disallowedPasswordMessageErrorText && disallowedPasswordMessageTerms && !MsgUtil.isPasswordMessageEnabled(subject, disallowedPasswordMessageTerms)) {
168+
throw new ComposerUserError(linkifyHtml(disallowedPasswordMessageErrorText, { target: '_blank' }));
169+
}
163170
// When DISABLE_FLOWCRYPT_HOSTED_PASSWORD_MESSAGES present, and recipients are missing a public key, and the user is using flowcrypt.com/shared-tenant-fes (not FES)
164171
if (this.view.clientConfiguration.shouldDisableFlowCryptHostedPasswordMessages() && !this.view.isCustomerUrlFesUsed()) {
165172
throw new ComposerUserError(Lang.compose.addMissingRecipientPubkeys);

extension/chrome/elements/compose.htm

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,8 @@ <h1 id="header_title" data-test="header-title">New Secure Message</h1>
254254
<script src="/lib/zxcvbn.js"></script>
255255
<script src="/lib/iso-8859-2.js"></script>
256256
<script src="/lib/forge.js"></script>
257+
<script src="/lib/linkify.min.js"></script>
258+
<script src="/lib/linkify-html.min.js"></script>
257259
<script src="compose.js" type="module"></script>
258260
</body>
259261
</html>

extension/js/common/client-configuration.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export type ClientConfigurationJson = {
3333
enforce_keygen_expire_months?: number;
3434
in_memory_pass_phrase_session_length?: number;
3535
prv_backup_to_designated_mailbox?: string;
36+
disallow_password_messages_for_terms?: string[];
37+
disallow_password_messages_error_text?: string;
3638
};
3739
/* eslint-enable @typescript-eslint/naming-convention */
3840

@@ -110,6 +112,21 @@ export class ClientConfiguration {
110112
return this.clientConfigurationJson.enforce_keygen_expire_months;
111113
};
112114

115+
/**
116+
* An array of strings to check against the subject of the composed password-protected message.
117+
* If any string in this array is found in the subject, an error alert must be displayed.
118+
*/
119+
public getDisallowPasswordMessagesForTerms = (): string[] | undefined => {
120+
return this.clientConfigurationJson.disallow_password_messages_for_terms?.filter(term => !!term);
121+
};
122+
123+
/**
124+
* The text to be displayed in the password message terms error alert
125+
*/
126+
public getDisallowPasswordMessagesErrorText = (): string | undefined => {
127+
return this.clientConfigurationJson.disallow_password_messages_error_text;
128+
};
129+
113130
/**
114131
* pass phrase session length to be configurable with client configuraiton
115132
* default 4 hours

extension/js/common/core/crypto/pgp/msg-util.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,31 @@ export class MsgUtil {
306306
return diagnosis;
307307
}
308308

309+
public static isPasswordMessageEnabled(subject: string, disallowTerms: string[]) {
310+
if (!subject || !Array.isArray(disallowTerms)) {
311+
return true; // If no subject or no terms to disallow, assume enabled
312+
}
313+
314+
const lowerSubject = subject.toLowerCase();
315+
316+
for (const term of disallowTerms) {
317+
// Escape term for regex
318+
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
319+
// Use regex to ensure the term appears as a separate token
320+
// (^|\W) ensures the term is at start or preceded by non-word char
321+
// (\W|$) ensures the term is followed by non-word char or end
322+
const regex = new RegExp(`(^|\\W)${escapedTerm}(\\W|$)`, 'i');
323+
324+
if (regex.test(lowerSubject)) {
325+
// Found a disallowed term as a separate token
326+
return false;
327+
}
328+
}
329+
330+
// No disallowed terms found as exact matches
331+
return true;
332+
}
333+
309334
private static async getSortedKeys(kiWithPp: KeyInfoWithIdentityAndOptionalPp[], msg: OpenPGP.Message<OpenPGP.Data>): Promise<SortedKeysForDecrypt> {
310335
const keys: SortedKeysForDecrypt = {
311336
encryptedFor: [],

test/source/mock/fes/shared-tenant-fes-endpoints.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ export type FesClientConfiguration = {
4949
allow_keys_openpgp_org_search_only_for_domains?: string[];
5050
disallow_keys_openpgp_org_search_for_domains?: string[];
5151
prv_backup_to_designated_mailbox?: string;
52+
disallow_password_messages_for_terms?: string[];
53+
disallow_password_messages_error_text?: string;
5254
};
5355
/* eslint-enable @typescript-eslint/naming-convention */
5456

test/source/tests/compose.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,43 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te
236236
})
237237
);
238238

239+
test(
240+
'[email protected] - disallow password protected message terms',
241+
testWithBrowser(async (t, browser) => {
242+
const acct = '[email protected]';
243+
const rules = getKeyManagerAutogenRules(t.context.urls!.port!);
244+
const disallowedPasswordMessageErrorText = 'Password-protected messages are disabled. Please check https://test.com';
245+
246+
t.context.mockApi!.configProvider = new ConfigurationProvider({
247+
attester: {
248+
pubkeyLookup: {},
249+
},
250+
ekm: {
251+
keys: [testConstants.existingPrv],
252+
},
253+
fes: {
254+
clientConfiguration: {
255+
...rules,
256+
// eslint-disable-next-line @typescript-eslint/naming-convention
257+
disallow_password_messages_for_terms: ['forbidden', 'test'],
258+
// eslint-disable-next-line @typescript-eslint/naming-convention
259+
disallow_password_messages_error_text: disallowedPasswordMessageErrorText,
260+
},
261+
},
262+
});
263+
const settingsPage = await BrowserRecipe.openSettingsLoginApprove(t, browser, acct);
264+
await SetupPageRecipe.autoSetupWithEKM(settingsPage);
265+
const composePage = await ComposePageRecipe.openStandalone(t, browser, acct);
266+
await ComposePageRecipe.fillMsg(composePage, { to: '[email protected]' }, 'forbidden subject');
267+
await composePage.waitAndClick('@action-send', { delay: 1 });
268+
await PageRecipe.checkModalLink(composePage, 'error', 'https://test.com');
269+
await PageRecipe.waitForModalAndRespond(composePage, 'error', {
270+
contentToCheck: disallowedPasswordMessageErrorText,
271+
clickOn: 'confirm',
272+
});
273+
})
274+
);
275+
239276
test(
240277
'compose - signed with entered pass phrase + will remember pass phrase in session',
241278
testWithBrowser(async (t, browser) => {

test/source/tests/page-recipe/abstract-page-recipe.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ export abstract class PageRecipe {
3939
}
4040
};
4141

42+
public static checkModalLink = async (controllable: Controllable, type: ModalType, linktoCheck: string) => {
43+
const modalContainer = await controllable.waitAny(`.ui-modal-${type}`, { timeout: 15 });
44+
const contentElement = await modalContainer.$('.swal2-html-container a');
45+
46+
const actualLink = await PageRecipe.getElementPropertyJson(contentElement!, 'textContent');
47+
if (!actualLink.includes(linktoCheck)) {
48+
throw new Error(`Expected link "${linktoCheck}" not found. Actual content: "${actualLink}"`);
49+
}
50+
};
51+
4252
public static waitForToastToAppearAndDisappear = async (controllable: Controllable, containsText: string | RegExp): Promise<void> => {
4353
await controllable.waitForContent('.ui-toast-title', containsText);
4454
await controllable.waitTillGone('.ui-toast-title');

test/source/tests/unit-node.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -904,6 +904,31 @@ ${testConstants.smimeCert}`),
904904
t.pass();
905905
});
906906

907+
test('[unit][MsgUtil.isPasswordMesageEnabled] test password protected message compliance', async t => {
908+
const disallowTerms = ['[Classification: Data Control: Internal Data Control]', 'droid', 'forbidden data'];
909+
910+
const subjectsToTestObj: { [key: string]: boolean } = {
911+
'[Classification: Data Control: Internal Data Control] Quarter results': false,
912+
'Conference information [Classification: Data Control: Internal Data Control]': false,
913+
'Classification: Data Control: Internal Data Control - Tomorrow meeting': true,
914+
'Internal Data Control - Finance monitoring': true,
915+
// term check should work only for exact matches - if we have droid in the list of strings,
916+
// password-protected messages shouldn't be disabled for subjects with Android word
917+
'Android phone update': true,
918+
'droid phone': false,
919+
// Check for case insensitive
920+
'DROiD phone': false,
921+
'[forbidden data] year results': false,
922+
};
923+
924+
for (const subject of Object.keys(subjectsToTestObj)) {
925+
const expectedValue = subjectsToTestObj[subject];
926+
const result = MsgUtil.isPasswordMessageEnabled(subject, disallowTerms);
927+
expect(expectedValue).to.equal(result);
928+
}
929+
t.pass();
930+
});
931+
907932
test('[unit][KeyUtil.parse] Correctly extracting email from SubjectAltName of S/MIME certificate', async t => {
908933
/*
909934
// generate a key pair

0 commit comments

Comments
 (0)