diff --git a/.vscode/settings.json b/.vscode/settings.json index a55decd6..b0f3e36a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,7 @@ "editor.formatOnSave": false, "eslint.validate": ["javascript", "javascriptreact", "typescript"], "editor.codeActionsOnSave": { - "source.fixAll": "explicit" + "source.fixAll.eslint": "explicit" }, "jest.jestCommandLine": "npm test --", "jest.autoRun": { diff --git a/src/Scripts/Strings.ts b/src/Scripts/Strings.ts index e0a3e575..2ed73fb3 100644 --- a/src/Scripts/Strings.ts +++ b/src/Scripts/Strings.ts @@ -115,14 +115,6 @@ export class Strings { return ""; } - public static mapValueToURL(text: string): string { - try { - return ["", this.htmlEncode(text), ""].join(""); - } catch { - return text; - } - } - // Join an array with char, dropping empty/missing entries public static joinArray(array: (string | number | null)[] | null, char: string): string { if (!array) return ""; diff --git a/src/Scripts/row/ArchivedRow.test.ts b/src/Scripts/row/ArchivedRow.test.ts index 9bb2887a..bb872dce 100644 --- a/src/Scripts/row/ArchivedRow.test.ts +++ b/src/Scripts/row/ArchivedRow.test.ts @@ -1,33 +1,78 @@ import { ArchivedRow } from "./ArchivedRow"; +import { Decoder } from "../2047"; import { Strings } from "../Strings"; -jest.mock("../Strings", () => ({ - // eslint-disable-next-line @typescript-eslint/naming-convention - Strings: { - mapHeaderToURL: jest.fn(), - mapValueToURL: jest.fn() - } -})); +jest.mock("../Strings", () => { + const actualStrings = jest.requireActual("../Strings") as typeof import("../Strings"); + + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + Strings: { + mapHeaderToURL: jest.fn((headerName: string, text?: string) => actualStrings.Strings.mapHeaderToURL(headerName, text)), + htmlEncode: jest.fn((value: string) => actualStrings.Strings.htmlEncode(value)) + } + }; +}); describe("ArchivedRow", () => { - const header = "testHeader"; + const header = "Archived-At"; const label = "testLabel"; let archivedRow: ArchivedRow; beforeEach(() => { - (Strings.mapHeaderToURL as jest.Mock).mockReturnValue("mockedHeaderURL"); - (Strings.mapValueToURL as jest.Mock).mockReturnValue("mockedValueURL"); + jest.clearAllMocks(); archivedRow = new ArchivedRow(header, label); }); it("should set url using Strings.mapHeaderToURL", () => { expect(Strings.mapHeaderToURL).toHaveBeenCalledWith(header, label); - expect(archivedRow.url).toBe("mockedHeaderURL"); + expect(archivedRow.url).toBe("testLabel"); + }); + + it("should return html href for bracketed http(s) links", () => { + archivedRow.value = ""; + const valueUrl = archivedRow.valueUrl; + + expect(valueUrl).toContain(" { + archivedRow.value = "https://example.test/path"; + const valueUrl = archivedRow.valueUrl; + expect(Strings.htmlEncode).toHaveBeenCalledWith(archivedRow.value); + expect(valueUrl).toBe("https://example.test/path"); }); - it("should return valueUrl using Strings.mapValueToURL", () => { - archivedRow["valueInternal"] = "internalValue"; - expect(archivedRow.valueUrl).toBe("mockedValueURL"); - expect(Strings.mapValueToURL).toHaveBeenCalledWith("internalValue"); + it("should html encode raw img payload instead of rendering executable HTML", () => { + archivedRow.value = ""; + const valueUrl = archivedRow.valueUrl; + expect(Strings.htmlEncode).toHaveBeenCalledWith(archivedRow.value); + expect(valueUrl).toContain("<img"); + expect(valueUrl).toContain(">"); + expect(valueUrl).not.toContain(" { + const encodedPayload = "=?UTF-8?B?PGltZyBzcmM9eCBvbmVycm9yPWFsZXJ0KCdYU1MnKT4=?="; + archivedRow.value = Decoder.clean2047Encoding(encodedPayload); + + expect(archivedRow.valueUrl).toContain("<img"); + expect(archivedRow.valueUrl).toContain(">"); + expect(archivedRow.valueUrl).not.toContain(" { + const url = "https://example.com/test/list/foo-users@lists.example.com/message/mysubject/"; + archivedRow.value = `<${url}>`; + const valueUrl = archivedRow.valueUrl; + + expect(valueUrl).toMatch("]+)>\s*$/); + if (match?.[1]) { + try { + const url = new URL(match[1]); + if (url.protocol === "http:" || url.protocol === "https:") { + const a = document.createElement("a"); + a.href = url.href; + a.target = "_blank"; + a.rel = "noopener noreferrer"; + a.textContent = url.href; + return a.outerHTML; + } + } catch { + // Fall through to encoded output. + } + } + + return Strings.htmlEncode(this.valueInternal); + } +} \ No newline at end of file diff --git a/src/Scripts/ui/newMobilePaneIosFrame.ts b/src/Scripts/ui/newMobilePaneIosFrame.ts index 2eb88547..a19803c3 100644 --- a/src/Scripts/ui/newMobilePaneIosFrame.ts +++ b/src/Scripts/ui/newMobilePaneIosFrame.ts @@ -22,6 +22,7 @@ import { ReceivedRow } from "../row/ReceivedRow"; import { Row } from "../row/Row"; import { SummaryRow } from "../row/SummaryRow"; import { escapeAndHighlight, getViolationsForRow, highlightHtml } from "../rules/ViolationUtils"; +import { Strings } from "../Strings"; // This is the "new-mobile" UI rendered in newMobilePaneIosFrame.html @@ -103,7 +104,7 @@ function addCalloutEntry(name: string, value: string | number | null, parent: HT const template = document.getElementById("popover-entry-template") as HTMLTemplateElement; const clone = template.content.cloneNode(true) as DocumentFragment; const p = clone.querySelector("p") as HTMLElement; - p.innerHTML = "" + name + ": " + value; + p.innerHTML = "" + name + ": " + Strings.htmlEncode(value.toString()); parent.appendChild(clone); } } @@ -234,10 +235,10 @@ function buildReceivedTab(viewModel: HeaderModel): void { timelineTime.textContent = currentTime.format("h:mm:ss"); const timelineSubtitle = innerClone.querySelector(".timeline-item-subtitle") as HTMLElement; - timelineSubtitle.innerHTML = "From: " + row.from; + timelineSubtitle.innerHTML = "From: " + Strings.htmlEncode(row.from.toString()); const timelineText = innerClone.querySelector(".timeline-item-text") as HTMLElement; - timelineText.innerHTML = "To: " + row.by; + timelineText.innerHTML = "To: " + Strings.htmlEncode(row.by.toString()); currentTimeEntry.appendChild(innerClone); } else { @@ -272,7 +273,7 @@ function buildReceivedTab(viewModel: HeaderModel): void { timelineTime.textContent = entryTime.format("h:mm:ss"); const timelineSubtitle = innerClone.querySelector(".timeline-item-subtitle") as HTMLElement; - timelineSubtitle.innerHTML = "To: " + row.by; + timelineSubtitle.innerHTML = "To: " + Strings.htmlEncode(row.by.toString()); const delayText = innerClone.querySelector(".delay-text") as HTMLElement; delayText.textContent = row.delay.value !== null ? String(row.delay.value) : "";