From 6c08bea5f7d61d532a58f455ac4ad713a929f1ee Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Fri, 8 May 2026 17:18:23 +0930 Subject: [PATCH] feat: start secuirity thingo --- package.json | 9 +- .../built-in/betterSeqtaSecurity/index.ts | 246 ++++++++++++++++++ src/plugins/index.ts | 2 + src/seqta/security/analyzeHtmlThreats.test.ts | 47 ++++ src/seqta/security/analyzeHtmlThreats.ts | 190 ++++++++++++++ src/seqta/security/blockedContentUi.ts | 234 +++++++++++++++++ src/seqta/security/incidentReport.ts | 156 +++++++++++ src/seqta/utils/Loaders/LoadEngageHomePage.ts | 33 ++- src/seqta/utils/Loaders/LoadHomePage.ts | 33 ++- vitest.config.ts | 15 ++ 10 files changed, 959 insertions(+), 6 deletions(-) create mode 100644 src/plugins/built-in/betterSeqtaSecurity/index.ts create mode 100644 src/seqta/security/analyzeHtmlThreats.test.ts create mode 100644 src/seqta/security/analyzeHtmlThreats.ts create mode 100644 src/seqta/security/blockedContentUi.ts create mode 100644 src/seqta/security/incidentReport.ts create mode 100644 vitest.config.ts diff --git a/package.json b/package.json index 27826d94..5d6961b0 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "dependency-graph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg", "release": "gh release create $npm_package_name@$npm_package_version ./dist/*.zip --generate-notes", "publish": "bun lib/publish.js --b", - "zip": "bedframe zip" + "zip": "bedframe zip", + "test": "vitest run" }, "targets": { "prod": { @@ -40,6 +41,7 @@ "@babel/runtime": "^7.26.9", "@bedframe/cli": "^0.1.2", "@crxjs/vite-plugin": "^2.4.0", + "@types/jsdom": "^28.0.1", "@types/mime-types": "^3.0.1", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", @@ -47,6 +49,7 @@ "dependency-cruiser": "^17.0.1", "eslint": "^9.33.0", "glob": "^11.0.1", + "jsdom": "^29.1.1", "mime-types": "^3.0.1", "prettier": "^3.5.3", "process": "^0.11.10", @@ -55,7 +58,8 @@ "sass-loader": "^16.0.5", "semver": "^7.7.1", "tailwindcss": "3", - "url": "^0.11.4" + "url": "^0.11.4", + "vitest": "^4.1.5" }, "dependencies": { "@bedframe/core": "^0.1.0", @@ -92,6 +96,7 @@ "flexsearch": "^0.8.147", "fuse.js": "^7.1.0", "idb": "^8.0.2", + "jspdf": "^4.2.1", "localforage": "^1.10.0", "lodash": "^4.17.21", "mathjs": "^14.4.0", diff --git a/src/plugins/built-in/betterSeqtaSecurity/index.ts b/src/plugins/built-in/betterSeqtaSecurity/index.ts new file mode 100644 index 00000000..80dbc0fb --- /dev/null +++ b/src/plugins/built-in/betterSeqtaSecurity/index.ts @@ -0,0 +1,246 @@ +/** + * BetterSEQTA Security — core XSS-focused protections for HTML rendered from SEQTA APIs. + * + * Execution vs detection: SEQTA loads message bodies in same-origin `iframe.userHTML`. + * Scripts may run during parse before our scan completes. We set `sandbox="allow-same-origin"` + * (without `allow-scripts`) on those iframes so script execution is suppressed while we can + * still read `contentDocument` for scanning and existing theme/CSS injection. + * + * The warning UI is mounted on document.body (fixed layer aligned to the reading pane) so + * React replacing `.uiFrameWrapper` / iframe siblings does not destroy it. + */ +import type { Plugin } from "../../core/types"; +import { + analyzeHtmlThreats, + type ThreatAnalysis, +} from "@/seqta/security/analyzeHtmlThreats"; +import { + mountBlockedContentUi, + SECURITY_MESSAGE_OVERLAY_CLASS, +} from "@/seqta/security/blockedContentUi"; +import { eventManager } from "@/seqta/utils/listeners/EventManager"; + +const USER_HTML_IFRAME_EVENT = "bssSecurityUserHtmlIframe"; + +const userHtmlIframeLoadHooked = new WeakSet(); + +/** Tear down body overlay + listeners for this iframe (safe navigation or cleanup). */ +const messageOverlayCleanups = new WeakMap void>(); + +function teardownMessageSecurityOverlay(iframe: HTMLIFrameElement): void { + const fn = messageOverlayCleanups.get(iframe); + if (fn) { + fn(); + messageOverlayCleanups.delete(iframe); + } +} + +function applyMessageIframeSandbox(iframe: HTMLIFrameElement): void { + if (iframe.dataset.bssUserHtmlSandbox === "1") return; + iframe.dataset.bssUserHtmlSandbox = "1"; + iframe.setAttribute("sandbox", "allow-same-origin"); +} + +function wipeIframeDocument(iframe: HTMLIFrameElement): void { + try { + const d = iframe.contentDocument; + if (!d) return; + d.open(); + d.write( + "", + ); + d.close(); + } catch { + /* ignore */ + } +} + +/** + * After we replace a malicious document, the iframe fires `load` again with this blank shell. + * That pass must not tear down the blocker UI or the iframe would “recover” for one frame. + */ +function isPostWipeBlankDocument(doc: Document): boolean { + const body = doc.body; + if (!body || body.childElementCount > 0) return false; + if ((body.textContent ?? "").trim().length > 0) return false; + const meta = doc.head?.querySelector('meta[charset="utf-8"]'); + if (!meta) return false; + return doc.documentElement.outerHTML.length < 800; +} + +/** + * Full-screen body layer positioned over the reading pane so SEQTA/React can replace iframe + * markup without removing this node. + */ +function mountBodyAnchoredMessageOverlay( + iframe: HTMLIFrameElement, + anchor: HTMLElement, + opts: { + analysis: ThreatAnalysis; + rawSnippet: string; + contextTitle?: string; + }, +): void { + teardownMessageSecurityOverlay(iframe); + + const shell = document.createElement("div"); + shell.className = SECURITY_MESSAGE_OVERLAY_CLASS; + Object.assign(shell.style, { + position: "fixed", + zIndex: "2147483646", + overflow: "hidden", + pointerEvents: "auto", + boxSizing: "border-box", + padding: "12px", + background: "rgba(24,24,27,0.35)", + }); + + const inner = document.createElement("div"); + Object.assign(inner.style, { + width: "100%", + height: "100%", + boxSizing: "border-box", + }); + shell.appendChild(inner); + + let raf = 0; + const syncRect = (): void => { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => { + if (!iframe.isConnected) { + teardownMessageSecurityOverlay(iframe); + return; + } + if (!anchor.isConnected) { + teardownMessageSecurityOverlay(iframe); + return; + } + const r = anchor.getBoundingClientRect(); + const pad = 10; + const left = Math.max(8, r.left - pad); + const top = Math.max(8, r.top - pad); + const width = Math.min(window.innerWidth - left - 8, r.width + pad * 2); + const height = Math.min(window.innerHeight - top - 8, r.height + pad * 2); + shell.style.left = `${left}px`; + shell.style.top = `${top}px`; + shell.style.width = `${Math.max(0, width)}px`; + shell.style.height = `${Math.max(0, height)}px`; + }); + }; + + syncRect(); + document.body.appendChild(shell); + + const ro = new ResizeObserver(syncRect); + ro.observe(anchor); + + window.addEventListener("resize", syncRect); + window.addEventListener("scroll", syncRect, true); + + const unmountPanel = mountBlockedContentUi(inner, { + surface: "message", + analysis: opts.analysis, + rawSnippet: opts.rawSnippet, + contextTitle: opts.contextTitle, + rootOverlay: true, + }); + + const cleanup = (): void => { + cancelAnimationFrame(raf); + ro.disconnect(); + window.removeEventListener("resize", syncRect); + window.removeEventListener("scroll", syncRect, true); + unmountPanel(); + shell.remove(); + }; + + messageOverlayCleanups.set(iframe, cleanup); +} + +function handleUserHtmlIframeLoaded(iframe: HTMLIFrameElement): void { + let idoc: Document | null = null; + try { + idoc = iframe.contentDocument; + } catch { + return; + } + if (!idoc?.documentElement) return; + + const wrapper = + iframe.closest(".uiFrameWrapper") ?? + iframe.closest(".iframeWrapper") ?? + iframe.parentElement; + if (!wrapper) return; + + if ( + iframe.dataset.bssAwaitingWipeLoad === "1" && + isPostWipeBlankDocument(idoc) + ) { + iframe.dataset.bssAwaitingWipeLoad = ""; + return; + } + + iframe.dataset.bssAwaitingWipeLoad = ""; + + teardownMessageSecurityOverlay(iframe); + + iframe.style.visibility = ""; + iframe.style.height = ""; + iframe.style.minHeight = ""; + + const html = idoc.documentElement.outerHTML; + const analysis = analyzeHtmlThreats(html); + if (!analysis.blocked) return; + + const pane = iframe.closest('[class*="ReadingPane__ReadingPane"]'); + const anchor = (pane ?? wrapper) as HTMLElement; + + iframe.dataset.bssAwaitingWipeLoad = "1"; + wipeIframeDocument(iframe); + iframe.style.visibility = "hidden"; + iframe.style.height = "0"; + iframe.style.minHeight = "0"; + + const subject = pane + ?.querySelector('[class*="Message__subject___"]') + ?.textContent?.trim(); + + mountBodyAnchoredMessageOverlay(iframe, anchor, { + analysis, + rawSnippet: html.slice(0, 50_000), + contextTitle: subject, + }); +} + +const betterSeqtaSecurityPlugin: Plugin = { + id: "better-seqta-security", + name: "BetterSEQTA Security", + description: + "Blocks risky HTML in messages and notices and surfaces administrator-ready incident reports.", + version: "1.0.0", + settings: {}, + + run: () => { + const { unregister } = eventManager.register( + USER_HTML_IFRAME_EVENT, + { + elementType: "iframe", + customCheck: (element) => element.classList.contains("userHTML"), + }, + (element) => { + const iframe = element as HTMLIFrameElement; + if (userHtmlIframeLoadHooked.has(iframe)) return; + userHtmlIframeLoadHooked.add(iframe); + applyMessageIframeSandbox(iframe); + + const onLoad = () => handleUserHtmlIframeLoaded(iframe); + iframe.addEventListener("load", onLoad); + queueMicrotask(onLoad); + }, + ); + + return unregister; + }, +}; + +export default betterSeqtaSecurityPlugin; diff --git a/src/plugins/index.ts b/src/plugins/index.ts index aa8779ad..b96b65d3 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -10,6 +10,7 @@ import assessmentsAveragePlugin from "./built-in/assessmentsAverage"; import profilePicturePlugin from "./built-in/profilePicture"; import assessmentsOverviewPlugin from "./built-in/assessmentsOverview"; import backgroundMusicPlugin from "./built-in/backgroundMusic"; +import betterSeqtaSecurityPlugin from "./built-in/betterSeqtaSecurity"; //import messageFoldersPlugin from "./built-in/messageFolders"; //import testPlugin from './built-in/test'; @@ -21,6 +22,7 @@ const pluginManager = PluginManager.getInstance(); // Register built-in plugins pluginManager.registerPlugin(themesPlugin); +pluginManager.registerPlugin(betterSeqtaSecurityPlugin); pluginManager.registerPlugin(animatedBackgroundPlugin); pluginManager.registerPlugin(assessmentsAveragePlugin); pluginManager.registerPlugin(notificationCollectorPlugin); diff --git a/src/seqta/security/analyzeHtmlThreats.test.ts b/src/seqta/security/analyzeHtmlThreats.test.ts new file mode 100644 index 00000000..691e9716 --- /dev/null +++ b/src/seqta/security/analyzeHtmlThreats.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { analyzeHtmlThreats } from "./analyzeHtmlThreats"; + +describe("analyzeHtmlThreats", () => { + it("does not flag benign HTML", () => { + const r = analyzeHtmlThreats("

Hello world

"); + expect(r.blocked).toBe(false); + expect(r.findings).toHaveLength(0); + }); + + it("flags script tags", () => { + const r = analyzeHtmlThreats('

x

'); + expect(r.blocked).toBe(true); + expect(r.findings.some((f) => f.kind === "script_tag")).toBe(true); + }); + + it("flags javascript: URLs", () => { + const r = analyzeHtmlThreats('click'); + expect(r.blocked).toBe(true); + expect(r.findings.some((f) => f.kind === "dangerous_url_scheme")).toBe( + true, + ); + }); + + it("flags inline event handlers", () => { + const r = analyzeHtmlThreats(''); + expect(r.blocked).toBe(true); + expect( + r.findings.some((f) => f.kind === "inline_event_handler"), + ).toBe(true); + }); + + it("allows data:image/png sources", () => { + const r = analyzeHtmlThreats( + 'i', + ); + expect(r.blocked).toBe(false); + }); + + it("flags data:text/html", () => { + const r = analyzeHtmlThreats( + '', + ); + expect(r.blocked).toBe(true); + }); +}); diff --git a/src/seqta/security/analyzeHtmlThreats.ts b/src/seqta/security/analyzeHtmlThreats.ts new file mode 100644 index 00000000..5f2a17ba --- /dev/null +++ b/src/seqta/security/analyzeHtmlThreats.ts @@ -0,0 +1,190 @@ +import DOMPurify from "dompurify"; + +export interface ThreatFinding { + kind: string; + detail: string; +} + +export interface ThreatAnalysis { + blocked: boolean; + findings: ThreatFinding[]; +} + +const INLINE_HANDLER_RE = /^on[a-z]+$/i; + +const DANGEROUS_SCHEME_RE = + /^\s*(javascript|vbscript|about\s*:|file\s*:)/i; + +/** Inline data URIs except common raster images (emails often embed PNG/JPEG). */ +function isDangerousDataUri(url: string): boolean { + const v = url.trim().toLowerCase(); + if (!v.startsWith("data:")) return false; + if (/^data:image\/(png|jpe?g|gif|webp|bmp)([;,]|$)/i.test(v)) return false; + return true; +} + +/** Patterns inside executable contexts (script bodies). */ +const SCRIPT_TEXT_SUSPICIOUS = + /\beval\s*\(|new\s+Function\s*\(|document\s*\.\s*write|\.execScript\s*\(/i; + +function addFinding( + findings: ThreatFinding[], + kind: string, + detail: string, +): void { + if (findings.some((f) => f.kind === kind && f.detail === detail)) return; + findings.push({ kind, detail }); +} + +function inspectUrlAttr(attrName: string, value: string): ThreatFinding[] { + const out: ThreatFinding[] = []; + const v = value.trim(); + if (!v) return out; + if (DANGEROUS_SCHEME_RE.test(v) || isDangerousDataUri(v)) { + out.push({ + kind: "dangerous_url_scheme", + detail: `${attrName}="${v.slice(0, 120)}${v.length > 120 ? "…" : ""}"`, + }); + } + return out; +} + +function walkElement(el: Element, findings: ThreatFinding[]): void { + const tag = el.tagName.toLowerCase(); + + if (tag === "script") { + const src = el.getAttribute("src")?.trim() ?? ""; + if ( + src && + (DANGEROUS_SCHEME_RE.test(src) || isDangerousDataUri(src)) + ) { + addFinding(findings, "script_src", `script src="${src.slice(0, 160)}"`); + } else if (!src && el.textContent && SCRIPT_TEXT_SUSPICIOUS.test(el.textContent)) { + addFinding( + findings, + "script_pattern", + "Inline script contains suspicious patterns (eval/new Function/document.write).", + ); + } else { + addFinding(findings, "script_tag", "A script element is present in HTML."); + } + return; + } + + if (tag === "meta") { + const httpEquiv = el.getAttribute("http-equiv")?.toLowerCase(); + if (httpEquiv === "refresh") { + addFinding( + findings, + "meta_refresh", + 'meta http-equiv="refresh" can redirect or execute unexpectedly.', + ); + } + } + + if (tag === "iframe" || tag === "frame") { + const src = el.getAttribute("src")?.trim() ?? ""; + const srcdoc = el.getAttribute("srcdoc") ?? ""; + findings.push(...inspectUrlAttr("iframe[src]", src)); + if (srcdoc.length > 0) { + addFinding( + findings, + "iframe_srcdoc", + "iframe srcdoc may embed arbitrary markup; nested analysis follows.", + ); + nestedAnalyze(srcdoc, findings, 2); + } + } + + if (tag === "object" || tag === "embed") { + const url = + el.getAttribute("data") ?? el.getAttribute("src") ?? ""; + findings.push(...inspectUrlAttr(`${tag}[src/data]`, url)); + } + + for (const attr of Array.from(el.attributes)) { + const name = attr.name; + if (INLINE_HANDLER_RE.test(name)) { + addFinding( + findings, + "inline_event_handler", + `${tag}.${name}`, + ); + } + const val = attr.value ?? ""; + if ( + name === "href" || + name === "src" || + name === "action" || + name === "formaction" || + name === "poster" || + name === "data" + ) { + findings.push(...inspectUrlAttr(`${tag}[${name}]`, val)); + } + if (name === "style" && /\burl\s*\(\s*["']?\s*javascript:/i.test(val)) { + addFinding(findings, "css_javascript_url", `${tag} style contains javascript: URL.`); + } + } + + for (const child of Array.from(el.children)) { + walkElement(child, findings); + } +} + +function nestedAnalyze(fragment: string, findings: ThreatFinding[], depth: number): void { + if (depth <= 0) return; + let doc: Document; + try { + doc = new DOMParser().parseFromString(fragment, "text/html"); + } catch { + return; + } + walkElement(doc.documentElement, findings); +} + +/** + * High-confidence HTML threat signals for user-generated / API HTML (messages, notices). + * + * Note: This runs after load for iframes in many cases; pairing with iframe `sandbox` + * (see BetterSEQTA Security plugin) is required for reliable script blocking — see plugin comments. + */ +export function analyzeHtmlThreats(html: string): ThreatAnalysis { + const findings: ThreatFinding[] = []; + + if (!html || !html.trim()) { + return { blocked: false, findings: [] }; + } + + let doc: Document; + try { + doc = new DOMParser().parseFromString(html, "text/html"); + } catch { + return { blocked: false, findings: [] }; + } + + walkElement(doc.documentElement, findings); + + /** SEQTA home modal path uses DOMPurify with onclick allowed; flag removal under stricter rules. */ + const permissive = DOMPurify.sanitize(html, { + ADD_ATTR: ["onclick"], + ALLOWED_URI_REGEXP: + /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|chrome-extension):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i, + }); + const strict = DOMPurify.sanitize(html, { + ALLOW_DATA_ATTR: false, + ALLOW_UNKNOWN_PROTOCOLS: false, + }); + if (strict !== permissive) { + addFinding( + findings, + "dompurify_delta", + "Content was altered under strict sanitization (likely inline handlers or risky markup).", + ); + } + + return { + blocked: findings.length > 0, + findings, + }; +} diff --git a/src/seqta/security/blockedContentUi.ts b/src/seqta/security/blockedContentUi.ts new file mode 100644 index 00000000..dda48c61 --- /dev/null +++ b/src/seqta/security/blockedContentUi.ts @@ -0,0 +1,234 @@ +import type { ThreatAnalysis } from "./analyzeHtmlThreats"; +import { + buildIncidentReport, + copyIncidentReport, + downloadIncidentReportPdf, + formatIncidentReportPlainText, + openIncidentReportEmail, + type IncidentReport, +} from "./incidentReport"; + +/** Mounted by BetterSEQTA Security on messages / notices when HTML is blocked. */ +export const SECURITY_BLOCK_HOST_CLASS = "bss-security-block-host"; +/** Body-fixed layer for message pane (survives React replacing iframe wrappers). */ +export const SECURITY_MESSAGE_OVERLAY_CLASS = "bss-security-message-overlay"; +const HOST_CLASS = SECURITY_BLOCK_HOST_CLASS; +const REPORT_LAYER_CLASS = "bss-security-report-layer"; + +function el( + tag: K, + style: Partial, + text?: string, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + Object.assign(node.style, style); + if (text !== undefined) node.textContent = text; + return node; +} + +function button( + label: string, + onClick: () => void, +): HTMLButtonElement { + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = label; + Object.assign(btn.style, { + padding: "10px 18px", + borderRadius: "8px", + border: "1px solid rgba(255,255,255,0.25)", + background: "rgba(220,38,38,0.35)", + color: "#fff", + cursor: "pointer", + fontSize: "14px", + fontWeight: "600", + transition: "background 0.2s, transform 0.2s", + } as CSSStyleDeclaration); + btn.addEventListener("mouseenter", () => { + btn.style.background = "rgba(220,38,38,0.55)"; + }); + btn.addEventListener("mouseleave", () => { + btn.style.background = "rgba(220,38,38,0.35)"; + }); + btn.addEventListener("click", onClick); + return btn; +} + +function removeExistingReportLayer(doc: Document): void { + doc.querySelectorAll(`.${REPORT_LAYER_CLASS}`).forEach((n) => n.remove()); +} + +export interface MountBlockedContentOptions { + surface: "message" | "notice"; + analysis: ThreatAnalysis; + rawSnippet?: string; + contextTitle?: string; + contextSubtitle?: string; + hostDocument?: Document; + /** + * Panel lives on document.body (fixed layer). Omits `position: relative` so the caller can pin position/size. + */ + rootOverlay?: boolean; +} + +export function mountBlockedContentUi( + container: HTMLElement, + options: MountBlockedContentOptions, +): () => void { + const doc = options.hostDocument ?? document; + container.innerHTML = ""; + container.classList.add(HOST_CLASS); + const basePanel: Partial = { + boxSizing: "border-box", + minHeight: options.rootOverlay ? "min(100%, 260px)" : "220px", + padding: "24px", + borderRadius: "12px", + background: + "linear-gradient(145deg, rgba(30,30,35,0.98), rgba(20,20,24,0.98))", + border: "1px solid rgba(239,68,68,0.45)", + color: "#f4f4f5", + fontFamily: "system-ui, Segoe UI, Roboto, sans-serif", + lineHeight: "1.5", + }; + if (options.rootOverlay) { + Object.assign(container.style, { + ...basePanel, + height: "100%", + overflow: "auto", + }); + } else { + Object.assign(container.style, { + ...basePanel, + position: "relative", + }); + } + + const title = el("h2", { + margin: "0 0 12px 0", + fontSize: "20px", + fontWeight: "700", + color: "#fff", + }, "BetterSEQTA Security"); + + const lead = el("p", { + margin: "0 0 12px 0", + fontSize: "15px", + color: "#e4e4e7", + }); + lead.textContent = + "This content was not shown because BetterSEQTA+ detected potentially malicious HTML (for example scripts or dangerous links). This helps protect your account from cross-site scripting."; + + const admin = el("p", { + margin: "0 0 20px 0", + fontSize: "14px", + color: "#a1a1aa", + }); + admin.textContent = + "Contact your school SEQTA or IT administrator so they can remove or fix the message or notice at source."; + + const actions = el("div", { + display: "flex", + flexWrap: "wrap", + gap: "12px", + alignItems: "center", + }); + + let latestReport: IncidentReport | null = null; + + const openReport = async () => { + latestReport = await buildIncidentReport({ + surface: options.surface, + analysis: options.analysis, + rawSnippet: options.rawSnippet, + contextTitle: options.contextTitle, + contextSubtitle: options.contextSubtitle, + }); + removeExistingReportLayer(doc); + const layer = doc.createElement("div"); + layer.className = REPORT_LAYER_CLASS; + Object.assign(layer.style, { + position: "fixed", + inset: "0", + zIndex: "2147483647", + background: "rgba(0,0,0,0.55)", + backdropFilter: "blur(4px)", + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "24px", + boxSizing: "border-box", + }); + + const panel = doc.createElement("div"); + Object.assign(panel.style, { + maxWidth: "640px", + width: "100%", + maxHeight: "85vh", + overflow: "auto", + background: "#18181b", + color: "#fafafa", + borderRadius: "12px", + border: "1px solid #3f3f46", + padding: "24px", + boxShadow: "0 25px 50px rgba(0,0,0,0.45)", + }); + + const pre = doc.createElement("pre"); + pre.style.whiteSpace = "pre-wrap"; + pre.style.wordBreak = "break-word"; + pre.style.fontSize = "12px"; + pre.style.lineHeight = "1.45"; + pre.style.margin = "0 0 16px 0"; + pre.textContent = formatIncidentReportPlainText(latestReport); + + const row = doc.createElement("div"); + row.style.display = "flex"; + row.style.flexWrap = "wrap"; + row.style.gap = "10px"; + + const closeLayer = () => layer.remove(); + + row.appendChild( + button("Close", closeLayer), + ); + row.appendChild( + button("Copy report", async () => { + if (!latestReport) return; + await copyIncidentReport(latestReport); + }), + ); + row.appendChild( + button("Download PDF", () => { + if (!latestReport) return; + downloadIncidentReportPdf(latestReport); + }), + ); + row.appendChild( + button("Email", () => { + if (!latestReport) return; + openIncidentReportEmail(latestReport); + }), + ); + + panel.appendChild(pre); + panel.appendChild(row); + layer.appendChild(panel); + layer.addEventListener("click", (e) => { + if (e.target === layer) closeLayer(); + }); + doc.body.appendChild(layer); + }; + + actions.appendChild(button("View report", () => void openReport())); + + container.appendChild(title); + container.appendChild(lead); + container.appendChild(admin); + container.appendChild(actions); + + return () => { + container.innerHTML = ""; + container.classList.remove(HOST_CLASS); + removeExistingReportLayer(doc); + }; +} diff --git a/src/seqta/security/incidentReport.ts b/src/seqta/security/incidentReport.ts new file mode 100644 index 00000000..5ac45fca --- /dev/null +++ b/src/seqta/security/incidentReport.ts @@ -0,0 +1,156 @@ +import { jsPDF } from "jspdf"; +import browser from "webextension-polyfill"; + +import type { ThreatAnalysis, ThreatFinding } from "./analyzeHtmlThreats"; + +export type ThreatSurface = "message" | "notice"; + +export interface IncidentReport { + generatedAtIso: string; + surface: ThreatSurface; + extensionVersion: string; + pageUrl: string; + contextTitle?: string; + contextSubtitle?: string; + analysis: ThreatAnalysis; + contentFingerprint?: string; +} + +async function sha256Hex(text: string): Promise { + const enc = new TextEncoder().encode(text); + const digest = await crypto.subtle.digest("SHA-256", enc); + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function getExtensionVersion(): string { + try { + return browser.runtime?.getManifest?.()?.version ?? "unknown"; + } catch { + return "unknown"; + } +} + +export async function buildIncidentReport(opts: { + surface: ThreatSurface; + analysis: ThreatAnalysis; + rawSnippet?: string; + contextTitle?: string; + contextSubtitle?: string; + extensionVersion?: string; +}): Promise { + let fingerprint: string | undefined; + if (opts.rawSnippet?.trim()) { + try { + if (typeof crypto !== "undefined" && crypto.subtle) { + fingerprint = await sha256Hex(opts.rawSnippet.slice(0, 50_000)); + } + } catch { + fingerprint = undefined; + } + } + + const version = opts.extensionVersion ?? getExtensionVersion(); + + return { + generatedAtIso: new Date().toISOString(), + surface: opts.surface, + extensionVersion: version, + pageUrl: + typeof window !== "undefined" ? window.location.href : "", + contextTitle: opts.contextTitle, + contextSubtitle: opts.contextSubtitle, + analysis: opts.analysis, + contentFingerprint: fingerprint, + }; +} + +function formatFindings(findings: ThreatFinding[]): string { + return findings.map((f, i) => `${i + 1}. [${f.kind}] ${f.detail}`).join("\n"); +} + +export function formatIncidentReportPlainText(report: IncidentReport): string { + const lines = [ + "BetterSEQTA+ Security — incident report", + "=====================================", + "", + `Generated (UTC): ${report.generatedAtIso}`, + `Surface: ${report.surface}`, + `Extension version: ${report.extensionVersion}`, + `Page URL: ${report.pageUrl}`, + ]; + if (report.contextTitle) lines.push(`Title / subject: ${report.contextTitle}`); + if (report.contextSubtitle) lines.push(`Detail: ${report.contextSubtitle}`); + if (report.contentFingerprint) { + lines.push(`Content SHA-256 (truncated input): ${report.contentFingerprint}`); + } + lines.push("", "Findings:", formatFindings(report.analysis.findings), ""); + lines.push( + "Next steps:", + "- Contact your school SEQTA / IT administrator and ask them to remove or sanitise the malicious content at source.", + "- Attach this report (PDF or pasted text) when reporting.", + ); + return lines.join("\n"); +} + +export async function copyIncidentReport(report: IncidentReport): Promise { + const text = formatIncidentReportPlainText(report); + await navigator.clipboard.writeText(text); +} + +export function downloadIncidentReportPdf(report: IncidentReport): void { + const doc = new jsPDF({ unit: "pt", format: "a4" }); + const margin = 48; + let y = margin; + const lineHeight = 14; + const pageHeight = doc.internal.pageSize.getHeight(); + const maxWidth = doc.internal.pageSize.getWidth() - margin * 2; + + const pushLines = (text: string, bold = false) => { + doc.setFont("helvetica", bold ? "bold" : "normal"); + const wrapped = doc.splitTextToSize(text, maxWidth) as string[]; + for (const line of wrapped) { + if (y > pageHeight - margin) { + doc.addPage(); + y = margin; + } + doc.text(line, margin, y); + y += lineHeight; + } + }; + + pushLines("BetterSEQTA+ Security — incident report", true); + pushLines(`Generated (UTC): ${report.generatedAtIso}`); + pushLines(`Surface: ${report.surface}`); + pushLines(`Extension version: ${report.extensionVersion}`); + pushLines(`Page URL: ${report.pageUrl}`); + if (report.contextTitle) pushLines(`Title / subject: ${report.contextTitle}`); + if (report.contextSubtitle) pushLines(`Detail: ${report.contextSubtitle}`); + if (report.contentFingerprint) { + pushLines(`Content SHA-256 (truncated input): ${report.contentFingerprint}`); + } + pushLines(""); + pushLines("Findings:", true); + for (const f of report.analysis.findings) { + pushLines(`• [${f.kind}] ${f.detail}`); + } + pushLines(""); + pushLines("Next steps:", true); + pushLines( + "Contact your school SEQTA / IT administrator and ask them to remove or sanitise the malicious content at source. Attach this PDF when reporting.", + ); + + doc.save(`betterseqta-security-report-${report.surface}-${Date.now()}.pdf`); +} + +export function openIncidentReportEmail(report: IncidentReport): void { + const subject = encodeURIComponent( + "SEQTA: suspected malicious HTML blocked by BetterSEQTA+ Security", + ); + const body = encodeURIComponent( + formatIncidentReportPlainText(report).slice(0, 1800) + + "\n\n[If truncated: use Copy in the report dialog for the full text.]", + ); + window.location.href = `mailto:?subject=${subject}&body=${body}`; +} diff --git a/src/seqta/utils/Loaders/LoadEngageHomePage.ts b/src/seqta/utils/Loaders/LoadEngageHomePage.ts index ece4e788..0a0a95c7 100644 --- a/src/seqta/utils/Loaders/LoadEngageHomePage.ts +++ b/src/seqta/utils/Loaders/LoadEngageHomePage.ts @@ -17,6 +17,8 @@ import { toISODate, weekRangeContaining, } from "@/seqta/utils/Loaders/engageParentTimetable"; +import { analyzeHtmlThreats } from "@/seqta/security/analyzeHtmlThreats"; +import { mountBlockedContentUi } from "@/seqta/security/blockedContentUi"; export function updateEngageHomeMenuActive(isHome: boolean): void { const home = document.getElementById("homebutton"); @@ -356,6 +358,12 @@ function openEngageNoticeModal( .replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "") .replace(/ +/, " "); + const threatAnalysis = analyzeHtmlThreats(cleanContent); + const noticeBlocked = threatAnalysis.blocked; + const noticeBodyInner = noticeBlocked + ? `
` + : `
${cleanContent}
`; + document.getElementById("notice-modal")?.remove(); const sourceRect = sourceElement.getBoundingClientRect(); @@ -389,7 +397,7 @@ function openEngageNoticeModal(

${notice.title}

-
${cleanContent}
+ ${noticeBodyInner} @@ -406,6 +414,23 @@ function openEngageNoticeModal( document.body.appendChild(modal); + if (noticeBlocked) { + const mountEl = modal.querySelector( + ".bss-security-notice-mount", + ) as HTMLElement | null; + if (mountEl) { + mountBlockedContentUi(mountEl, { + surface: "notice", + analysis: threatAnalysis, + rawSnippet: cleanContent.slice(0, 50_000), + contextTitle: String(notice.title ?? ""), + contextSubtitle: [notice.staff, notice.label_title] + .filter(Boolean) + .join(" · "), + }); + } + } + sourceElement.setAttribute("data-transitioning", "true"); sourceElement.style.opacity = "0"; sourceElement.style.transform = "scale(0.95)"; @@ -431,7 +456,11 @@ function openEngageNoticeModal(

${notice.title}

-
${cleanContent}
+ ${ + noticeBlocked + ? `
` + : `
${cleanContent}
` + } `; document.body.appendChild(tempMeasureDiv); diff --git a/src/seqta/utils/Loaders/LoadHomePage.ts b/src/seqta/utils/Loaders/LoadHomePage.ts index dcbd553d..a9a3dd2f 100644 --- a/src/seqta/utils/Loaders/LoadHomePage.ts +++ b/src/seqta/utils/Loaders/LoadHomePage.ts @@ -13,6 +13,8 @@ import { CreateElement } from "@/seqta/utils/CreateEnable/CreateElement"; import { FilterUpcomingAssessments } from "@/seqta/utils/FilterUpcomingAssessments"; import { getMockNotices } from "@/seqta/ui/dev/hideSensitiveContent"; import { setupFixedTooltips } from "@/seqta/utils/fixedTooltip"; +import { analyzeHtmlThreats } from "@/seqta/security/analyzeHtmlThreats"; +import { mountBlockedContentUi } from "@/seqta/security/blockedContentUi"; let LessonInterval: any; let currentSelectedDate = new Date(); @@ -384,6 +386,12 @@ function openNoticeModal( .replace(/\[\[[\w]+[:][\w]+[\]\]]+/g, "") .replace(/ +/, " "); + const threatAnalysis = analyzeHtmlThreats(cleanContent); + const noticeBlocked = threatAnalysis.blocked; + const noticeBodyInner = noticeBlocked + ? `
` + : `
${cleanContent}
`; + document.getElementById("notice-modal")?.remove(); const sourceRect = sourceElement.getBoundingClientRect(); @@ -417,7 +425,7 @@ function openNoticeModal(

${notice.title}

-
${cleanContent}
+ ${noticeBodyInner} @@ -434,6 +442,23 @@ function openNoticeModal( document.body.appendChild(modal); + if (noticeBlocked) { + const mountEl = modal.querySelector( + ".bss-security-notice-mount", + ) as HTMLElement | null; + if (mountEl) { + mountBlockedContentUi(mountEl, { + surface: "notice", + analysis: threatAnalysis, + rawSnippet: cleanContent.slice(0, 50_000), + contextTitle: String(notice.title ?? ""), + contextSubtitle: [notice.staff, notice.label_title] + .filter(Boolean) + .join(" · "), + }); + } + } + sourceElement.setAttribute("data-transitioning", "true"); sourceElement.style.opacity = "0"; sourceElement.style.transform = "scale(0.95)"; @@ -459,7 +484,11 @@ function openNoticeModal(

${notice.title}

-
${cleanContent}
+ ${ + noticeBlocked + ? `
` + : `
${cleanContent}
` + } `; document.body.appendChild(tempMeasureDiv); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..9a22cb49 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,15 @@ +import path from "path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + globals: true, + include: ["src/**/*.test.ts"], + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +});