Skip to content
This repository was archived by the owner on Jul 30, 2026. It is now read-only.
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
3 changes: 3 additions & 0 deletions public/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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..."
}
Expand Down
3 changes: 3 additions & 0 deletions public/_locales/nl/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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..."
}
Expand Down
10 changes: 7 additions & 3 deletions src/pages/yivi-popup/init-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,16 @@ export async function initCryptoPopup(deps: InitCryptoPopupDeps): Promise<void>
}, 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);
}
Expand Down
62 changes: 59 additions & 3 deletions tests/crypto-popup.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down