mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-06 03:34:40 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c08bea5f7 |
+7
-2
@@ -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",
|
||||
|
||||
@@ -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<HTMLIFrameElement>();
|
||||
|
||||
/** Tear down body overlay + listeners for this iframe (safe navigation or cleanup). */
|
||||
const messageOverlayCleanups = new WeakMap<HTMLIFrameElement, () => 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(
|
||||
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"></head><body></body></html>",
|
||||
);
|
||||
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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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("<p>Hello <strong>world</strong></p>");
|
||||
expect(r.blocked).toBe(false);
|
||||
expect(r.findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("flags script tags", () => {
|
||||
const r = analyzeHtmlThreats('<p>x</p><script>alert(1)</script>');
|
||||
expect(r.blocked).toBe(true);
|
||||
expect(r.findings.some((f) => f.kind === "script_tag")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags javascript: URLs", () => {
|
||||
const r = analyzeHtmlThreats('<a href="javascript:void(0)">click</a>');
|
||||
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('<img src="https://example.com/x.png" onerror="alert(1)">');
|
||||
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(
|
||||
'<img alt="i" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==">',
|
||||
);
|
||||
expect(r.blocked).toBe(false);
|
||||
});
|
||||
|
||||
it("flags data:text/html", () => {
|
||||
const r = analyzeHtmlThreats(
|
||||
'<iframe src="data:text/html,%3Cscript%3E%3C/script%3E"></iframe>',
|
||||
);
|
||||
expect(r.blocked).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
style: Partial<CSSStyleDeclaration>,
|
||||
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<CSSStyleDeclaration> = {
|
||||
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);
|
||||
};
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<IncidentReport> {
|
||||
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<void> {
|
||||
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}`;
|
||||
}
|
||||
@@ -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
|
||||
? `<div class="notice-content-body bss-security-notice-mount"></div>`
|
||||
: `<div class="notice-content-body">${cleanContent}</div>`;
|
||||
|
||||
document.getElementById("notice-modal")?.remove();
|
||||
|
||||
const sourceRect = sourceElement.getBoundingClientRect();
|
||||
@@ -389,7 +397,7 @@ function openEngageNoticeModal(
|
||||
<button class="notice-close-btn">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
<div class="notice-content-body">${cleanContent}</div>
|
||||
${noticeBodyInner}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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(
|
||||
<button class="notice-close-btn">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
<div class="notice-content-body">${cleanContent}</div>
|
||||
${
|
||||
noticeBlocked
|
||||
? `<div class="notice-content-body bss-security-notice-mount" style="min-height:280px;"></div>`
|
||||
: `<div class="notice-content-body">${cleanContent}</div>`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(tempMeasureDiv);
|
||||
|
||||
@@ -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
|
||||
? `<div class="notice-content-body bss-security-notice-mount"></div>`
|
||||
: `<div class="notice-content-body">${cleanContent}</div>`;
|
||||
|
||||
document.getElementById("notice-modal")?.remove();
|
||||
|
||||
const sourceRect = sourceElement.getBoundingClientRect();
|
||||
@@ -417,7 +425,7 @@ function openNoticeModal(
|
||||
<button class="notice-close-btn">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
<div class="notice-content-body">${cleanContent}</div>
|
||||
${noticeBodyInner}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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(
|
||||
<button class="notice-close-btn">×</button>
|
||||
</div>
|
||||
<h2 class="notice-content-title">${notice.title}</h2>
|
||||
<div class="notice-content-body">${cleanContent}</div>
|
||||
${
|
||||
noticeBlocked
|
||||
? `<div class="notice-content-body bss-security-notice-mount" style="min-height:280px;"></div>`
|
||||
: `<div class="notice-content-body">${cleanContent}</div>`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(tempMeasureDiv);
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user