diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json index 6fc90b9..aa03e25 100644 --- a/public/_locales/en/messages.json +++ b/public/_locales/en/messages.json @@ -134,6 +134,9 @@ "uploadSessionExpired": { "message": "The encrypted upload session expired before it could finish. Please send the message again." }, + "operationFailed": { + "message": "The operation could not be completed. Please try again." + }, "decryptingButton": { "message": "Decrypting..." } diff --git a/public/_locales/nl/messages.json b/public/_locales/nl/messages.json index 7aa61ec..b0dd669 100644 --- a/public/_locales/nl/messages.json +++ b/public/_locales/nl/messages.json @@ -134,6 +134,9 @@ "uploadSessionExpired": { "message": "De versleutelde uploadsessie is verlopen voordat deze kon worden voltooid. Verstuur het bericht opnieuw." }, + "operationFailed": { + "message": "De bewerking kon niet worden voltooid. Probeer het opnieuw." + }, "decryptingButton": { "message": "Ontsleutelen..." } diff --git a/src/pages/yivi-popup/init-flow.ts b/src/pages/yivi-popup/init-flow.ts index 3b48a99..119521b 100644 --- a/src/pages/yivi-popup/init-flow.ts +++ b/src/pages/yivi-popup/init-flow.ts @@ -92,12 +92,16 @@ export async function initCryptoPopup(deps: InitCryptoPopupDeps): Promise }, deps.autoCloseDelayMs ?? DEFAULT_AUTO_CLOSE_MS); } catch (e) { console.error("[PostGuard] Crypto popup error:", e); + // Only surface messages from error types we know are safe to show. + // Raw SDK error text can embed internal server URLs or subsystem + // names, so it is logged above (console.error) but never forwarded + // to the popup UI or sent to the background — the user sees a + // generic fallback instead. Add new cases here only for error types + // whose `.message` is verified safe for display. const message = e instanceof UploadSessionExpiredError ? deps.i18n.getMessage("uploadSessionExpired") - : e instanceof Error - ? e.message - : "Operation failed."; + : deps.i18n.getMessage("operationFailed"); await deps.sendError(windowId, message).catch(() => undefined); deps.ui.showError(message); } diff --git a/tests/crypto-popup.test.ts b/tests/crypto-popup.test.ts index 490abfd..19a5581 100644 --- a/tests/crypto-popup.test.ts +++ b/tests/crypto-popup.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; +import { UploadSessionExpiredError } from "@e4a/pg-js"; import { buildRecipients } from "../src/pages/yivi-popup/recipients"; import { runEncryptInPopup, YIVI_ELEMENT_SELECTOR } from "../src/pages/yivi-popup/encrypt-popup"; import { runDecryptInPopup } from "../src/pages/yivi-popup/decrypt-popup"; @@ -556,7 +557,9 @@ describe("crypto popup — error handling", () => { i18n: { getMessage: (k) => k }, scheduleAutoClose: () => undefined, }); - expect(sendError).toHaveBeenCalledWith(8, "encrypt boom"); + // Raw SDK error text ("encrypt boom") must NOT leak — the generic + // i18n fallback is sent to the background instead. + expect(sendError).toHaveBeenCalledWith(8, "operationFailed"); }); it("should send error message to background on decrypt failure", async () => { @@ -577,7 +580,9 @@ describe("crypto popup — error handling", () => { i18n: { getMessage: (k) => k }, scheduleAutoClose: () => undefined, }); - expect(sendError).toHaveBeenCalledWith(9, "decrypt boom"); + // Raw SDK error text ("decrypt boom") must NOT leak — the generic + // i18n fallback is sent to the background instead. + expect(sendError).toHaveBeenCalledWith(9, "operationFailed"); }); it("should display error in popup UI", async () => { @@ -597,7 +602,58 @@ describe("crypto popup — error handling", () => { i18n: { getMessage: (k) => k }, scheduleAutoClose: () => undefined, }); - expect(ui.error).toBe("UI boom"); + // Raw SDK error text ("UI boom") must NOT be shown — the popup UI + // displays the generic i18n fallback instead. + expect(ui.error).toBe("operationFailed"); + }); + + it("should surface the i18n message for the safe UploadSessionExpiredError type", async () => { + const ui = makeUi(); + const data = fakeData("encrypt"); + const sendError = vi.fn(async () => undefined); + await initCryptoPopup({ + resolveWindowId: async () => 7, + requestInitData: async () => data, + createPg: () => ({} as any), + runEncrypt: async () => { + throw new UploadSessionExpiredError("test-uuid", "expired_or_unknown", ""); + }, + runDecrypt: async () => undefined, + sendError, + closeWindow: async () => undefined, + ui, + i18n: { getMessage: (k) => k }, + scheduleAutoClose: () => undefined, + }); + expect(sendError).toHaveBeenCalledWith(7, "uploadSessionExpired"); + expect(ui.error).toBe("uploadSessionExpired"); + }); + + it("should not leak sensitive SDK error text to the UI or background", async () => { + const ui = makeUi(); + const data = fakeData("encrypt"); + const sendError = vi.fn(async () => undefined); + const secret = "connect ECONNREFUSED https://internal-pkg.corp:8443/v2/request"; + await initCryptoPopup({ + resolveWindowId: async () => 3, + requestInitData: async () => data, + createPg: () => ({} as any), + runEncrypt: async () => { + throw new Error(secret); + }, + runDecrypt: async () => undefined, + sendError, + closeWindow: async () => undefined, + ui, + i18n: { getMessage: (k) => k }, + scheduleAutoClose: () => undefined, + }); + expect(sendError).toHaveBeenCalledWith(3, "operationFailed"); + expect(ui.error).toBe("operationFailed"); + // The internal server URL / subsystem detail must never reach the UI + // or the background channel. + expect(sendError).not.toHaveBeenCalledWith(3, secret); + expect(ui.error).not.toContain("internal-pkg.corp"); }); it("should not auto-close on error", async () => {