Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 44 additions & 4 deletions extension/chrome/elements/attachment_preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,7 @@ View.run(
this.attachmentPreviewContainer.html(`<div class="attachment-preview-txt">${Xss.escape(result.toUtfStr()).replace(/\n/g, '<br>')}</div>`); // xss-escaped
} else if (attachmentType === 'pdf') {
// PDF
// .slice() is used to copy attachment data https://github.com/FlowCrypt/flowcrypt-browser/issues/5408
const pdf = await pdfjsLib.getDocument({ data: result.slice() }).promise;
const previewPdf = new AttachmentPreviewPdf(this.attachmentPreviewContainer, pdf);
await previewPdf.render();
await this.renderPdf(result);
}
} else {
// no preview available, download button
Expand Down Expand Up @@ -122,5 +119,48 @@ View.run(
}
throw new DecryptionError(result as DecryptError);
};

private renderPdf = async (data: Uint8Array, password?: string) => {
try {
// .slice() is used to copy attachment data https://github.com/FlowCrypt/flowcrypt-browser/issues/5408
const loadingOptions: { data: Uint8Array; password?: string } = { data: data.slice() };
if (password) {
loadingOptions.password = password;
}
const pdf = await pdfjsLib.getDocument(loadingOptions).promise;
const previewPdf = new AttachmentPreviewPdf(this.attachmentPreviewContainer, pdf);
await previewPdf.render();
} catch (e) {
if (e instanceof Error && e.name === 'PasswordException') {
const error = e as Error & { code: number };
const needsPassword = error.code === 1; // PasswordException.NEED_PASSWORD
const incorrectPassword = error.code === 2; // PasswordException.INCORRECT_PASSWORD

if (needsPassword || incorrectPassword) {
// Show password prompt with appropriate message
const message = incorrectPassword
? 'Incorrect password. Please enter the correct password:'
: 'This PDF is password protected. Please enter the password:';
const userPassword = await this.promptForPdfPassword(message);

if (userPassword) {
// User provided a password, try again
await this.renderPdf(data, userPassword);
} else {
// User cancelled the password prompt
this.attachmentPreviewContainer.html('<div class="attachment-preview-unavailable">PDF is password protected. Password required to view.</div>'); // xss-sanitized
$('#attachment-preview-download').appendTo('.attachment-preview-unavailable').css('display', 'flex');
$('#attachment-preview-filename').text(this.origNameBasedOnFilename);
}
}
} else {
throw e;
}
}
};

private promptForPdfPassword = async (message?: string): Promise<string | undefined> => {
return await Ui.modal.passwordInput('Password Required', message || 'This PDF is password protected. Please enter the password:');
};
}
);
2 changes: 2 additions & 0 deletions extension/css/cryptup.css
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ span.gray {
.swal2-popup .swal2-styled.swal2-confirm.ui-modal-info-confirm,
.swal2-popup .swal2-styled.swal2-confirm.ui-modal-confirm-confirm,
.swal2-popup .swal2-styled.swal2-confirm.ui-modal-confirm-checkbox-confirm,
.swal2-popup .swal2-styled.swal2-confirm.ui-modal-password-input-confirm,
.button.green {
background-color: #31a217;
color: white;
Expand All @@ -179,6 +180,7 @@ button.btn_disabled,
.swal2-popup .swal2-styled.swal2-confirm.ui-modal-warning-confirm,
.swal2-popup .swal2-styled.swal2-cancel.ui-modal-confirm-cancel,
.swal2-popup .swal2-styled.swal2-cancel.ui-modal-confirm-checkbox-cancel,
.swal2-popup .swal2-styled.swal2-cancel.ui-modal-password-input-cancel,
.button.gray {
background-color: #b7b7b7;
background-image: linear-gradient(#c5c5c5, #b7b7b7);
Expand Down
23 changes: 23 additions & 0 deletions extension/js/common/browser/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,29 @@ export class Ui {
const { dismiss } = await userResponsePromise;
return typeof dismiss === 'undefined';
},
passwordInput: async (title: string, text: string): Promise<string | undefined> => {
const result = await Ui.swal().fire({
title,
text,
input: 'password',
inputPlaceholder: 'Enter password',
showCancelButton: true,
confirmButtonText: 'Submit',
cancelButtonText: 'Cancel',
allowOutsideClick: false,
inputAttributes: {
autocapitalize: 'off',
autocorrect: 'off',
},
customClass: {
popup: 'ui-modal-password-input',
confirmButton: 'ui-modal-password-input-confirm',
cancelButton: 'ui-modal-password-input-cancel',
input: 'ui-modal-password-input-field',
},
});
return result.isConfirmed && result.value ? (result.value as string) : undefined;
},
page: async (htmlUrl: string, replaceNewlines = false): Promise<void> => {
let html = await (await fetch(htmlUrl)).text();
html = Xss.htmlSanitize(html);
Expand Down

Large diffs are not rendered by default.

64 changes: 64 additions & 0 deletions test/source/tests/decrypt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2296,5 +2296,69 @@ XZ8r4OC6sguP/yozWlkG+7dDxsgKQVBENeG6Lw==
expect(Object.keys(downloadedFile8)[0]).to.match(/sample.*\.bat/);
})
);

test(
'decrypt - password protected PDF attachment',
testWithBrowser(async (t, browser) => {
const threadId = '19983afe57200dcd';
const { acctEmail } = await BrowserRecipe.setupCommonAcctWithAttester(t, browser, 'ci.tests.gmail');
const inboxPage = await browser.newExtensionPage(t, `chrome/settings/inbox/inbox.htm?acctEmail=${acctEmail}&threadId=${threadId}`);
await inboxPage.waitForSelTestState('ready');
await inboxPage.waitAll('iframe');

// Check that the attachment frame is present
const attachmentFrame = await inboxPage.getFrame(['attachment.htm']);
await attachmentFrame.waitForSelTestState('ready');

// Click on the attachment to preview it
await attachmentFrame.waitAndClick('@attachment-container');

// Wait for attachment preview frame
const attachmentPreviewPage = await inboxPage.getFrame(['attachment_preview.htm']);

// Wait for password modal and enter the correct password
await PageRecipe.waitForModalAndRespond(attachmentPreviewPage, 'password-input', {
contentToCheck: 'This PDF is password protected. Please enter the password:',
inputValue: '123456',
clickOn: 'confirm',
});

// Wait for the PDF to render
await attachmentPreviewPage.waitAny('.attachment-preview-pdf');

// Verify PDF content is displayed
const pdfCanvas = await attachmentPreviewPage.waitAny('.attachment-preview-pdf-page');
expect(pdfCanvas).to.not.be.null;

// Test incorrect password scenario
await inboxPage.close();
const inboxPage2 = await browser.newExtensionPage(t, `chrome/settings/inbox/inbox.htm?acctEmail=${acctEmail}&threadId=${threadId}`);
await inboxPage2.waitForSelTestState('ready');
await inboxPage2.waitAll('iframe');

const attachmentFrame2 = await inboxPage2.getFrame(['attachment.htm']);
await attachmentFrame2.waitForSelTestState('ready');
await attachmentFrame2.waitAndClick('@attachment-container');

const attachmentPreviewPage2 = await inboxPage2.getFrame(['attachment_preview.htm']);

// Enter wrong password first
await PageRecipe.waitForModalAndRespond(attachmentPreviewPage2, 'password-input', {
contentToCheck: 'This PDF is password protected. Please enter the password:',
inputValue: 'wrongpassword',
clickOn: 'confirm',
});

// Should get incorrect password modal
await PageRecipe.waitForModalAndRespond(attachmentPreviewPage2, 'password-input', {
contentToCheck: 'Incorrect password. Please enter the correct password:',
inputValue: '123456',
clickOn: 'confirm',
});

// Verify PDF renders after correct password
await attachmentPreviewPage2.waitAny('.attachment-preview-pdf');
})
);
}
};
11 changes: 9 additions & 2 deletions test/source/tests/page-recipe/abstract-page-recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ type ModalOpts = {
clickOn?: 'confirm' | 'cancel';
getTriggeredPage?: boolean;
timeout?: number;
inputValue?: string;
};
type ModalType = 'confirm' | 'error' | 'info' | 'warning';
type ModalType = 'confirm' | 'error' | 'info' | 'warning' | 'password-input';

export abstract class PageRecipe {
public static getElementPropertyJson = async (elem: ElementHandle, property: string) => {
Expand All @@ -23,7 +24,7 @@ export abstract class PageRecipe {
return await elem.evaluate((el, attribute) => el.getAttribute(attribute), attribute);
};

public static waitForModalAndRespond = async (controllable: Controllable, type: ModalType, { contentToCheck, clickOn, timeout }: ModalOpts) => {
public static waitForModalAndRespond = async (controllable: Controllable, type: ModalType, { contentToCheck, clickOn, timeout, inputValue }: ModalOpts) => {
const modalContainer = await controllable.waitAny(`.ui-modal-${type}`, { timeout });
if (typeof contentToCheck !== 'undefined') {
const contentElement = await modalContainer.$('.swal2-html-container');
Expand All @@ -33,6 +34,12 @@ export abstract class PageRecipe {
throw new Error(`Expected modal to contain "${contentToCheck}" but contained "${actualContent}"`);
}
}
if (inputValue) {
const inputElement = await modalContainer.$('input.swal2-input');
if (inputElement) {
await inputElement.type(inputValue);
}
}
if (clickOn) {
const button = await modalContainer.$(`button.ui-modal-${type}-${clickOn}`);
await button?.click();
Expand Down
Loading