mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat: implement LAN bind no-auth warning logic and related state management; add tests for banner visibility and dismissal
This commit is contained in:
parent
4d3fa4cf87
commit
6977c7d65b
14 changed files with 435 additions and 430 deletions
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -4,6 +4,7 @@ import { reactive } from "vue";
|
|||
const globalState = reactive({
|
||||
authSessionResolved: true,
|
||||
authEnabled: false,
|
||||
isLoopbackBind: true,
|
||||
authenticated: false,
|
||||
pluginsEnabled: true,
|
||||
detailedOutboundSendStatus: false,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* Optional runtime override for micron-parser-go WASM (IndexedDB).
|
||||
* GitHub releases are verified with SHASUMS256.txt before install.
|
||||
* Install via local .wasm upload only. Build ships bundled WASM under /vendor/.
|
||||
*/
|
||||
|
||||
const DB_NAME = "meshchatx_micron_wasm_override";
|
||||
|
|
@ -8,43 +8,12 @@ const DB_VERSION = 1;
|
|||
const STORE = "kv";
|
||||
const KEY = "runtime_override";
|
||||
|
||||
/** @typedef {{ source: "github"|"upload", releaseTag: string, wasmSri: string, wasmBytes: ArrayBuffer, expectedSha256Hex: string|null }} MicronWasmRuntimeOverrideRecord */
|
||||
|
||||
export const MICRON_PARSER_GO_RELEASE_DOWNLOAD_BASE =
|
||||
"https://github.com/Quad4-Software/Micron-Parser-Go/releases/download";
|
||||
/** @typedef {{ source: "upload", releaseTag: string, wasmSri: string, wasmBytes: ArrayBuffer, expectedSha256Hex: string|null }} MicronWasmRuntimeOverrideRecord */
|
||||
|
||||
export const WASM_FILENAME = "micron-parser-go.wasm";
|
||||
export const SHASUMS256_FILENAME = "SHASUMS256.txt";
|
||||
|
||||
export const MAX_WASM_OVERRIDE_BYTES = 14 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Same-origin MeshChat API path; server proxies only Quad4-Software/Micron-Parser-Go release assets (CSP).
|
||||
* @param {string} tag
|
||||
* @param {string} assetName SHASUMS256.txt | micron-parser-go.wasm
|
||||
*/
|
||||
export function micronParserGoReleaseProxyUrl(tag, assetName) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("tag", tag);
|
||||
params.set("asset", assetName);
|
||||
return `/api/v1/tools/micron-parser-go-release?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function readFetchErrorDetail(res) {
|
||||
const ct = res.headers.get("content-type") || "";
|
||||
if (ct.includes("application/json")) {
|
||||
try {
|
||||
const j = await res.json();
|
||||
if (j && typeof j.error === "string" && j.error) {
|
||||
return j.error;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return String(res.status);
|
||||
}
|
||||
|
||||
function openDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
|
@ -59,34 +28,6 @@ function openDb() {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses GNU/BSD shasum output for a single filename.
|
||||
* @param {string} text
|
||||
* @param {string} filename e.g. micron-parser-go.wasm
|
||||
* @returns {string|null} lowercase hex sha256 or null if not found
|
||||
*/
|
||||
export function parseShasums256ForFilename(text, filename) {
|
||||
if (text == null || filename == null) {
|
||||
return null;
|
||||
}
|
||||
const lines = String(text).split(/\r?\n/);
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
const m = line.match(/^([a-fA-F0-9]{64})\s+\*?(\S+)\s*$/);
|
||||
if (!m) {
|
||||
continue;
|
||||
}
|
||||
const name = m[2].trim();
|
||||
if (name === filename || name.endsWith(`/${filename}`)) {
|
||||
return m[1].toLowerCase();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param {ArrayBuffer} buf */
|
||||
export async function sha256HexOfBuffer(buf) {
|
||||
const d = await crypto.subtle.digest("SHA-256", buf);
|
||||
|
|
@ -105,79 +46,6 @@ export async function computeWasmSriSha384(buf) {
|
|||
return `sha384-${base64}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches SHASUMS256.txt and WASM from a GitHub release tag; verifies SHA-256 of WASM.
|
||||
* Does not write to storage.
|
||||
* @param {string} tag e.g. v1.0.5
|
||||
* @param {{ signal?: AbortSignal }} [opts]
|
||||
* @returns {Promise<Omit<MicronWasmRuntimeOverrideRecord, never>>}
|
||||
*/
|
||||
export async function fetchWasmFromGitHubReleaseVerified(tag, opts = {}) {
|
||||
const { signal } = opts;
|
||||
const t = String(tag || "").trim();
|
||||
if (!t) {
|
||||
throw new Error("Micron WASM update: release tag is required");
|
||||
}
|
||||
let sumsRes;
|
||||
try {
|
||||
sumsRes = await fetch(micronParserGoReleaseProxyUrl(t, SHASUMS256_FILENAME), { signal, cache: "no-store" });
|
||||
} catch (e) {
|
||||
const msg = e && e.name === "AbortError" ? "Request aborted" : (e && e.message) || String(e);
|
||||
throw new Error(`Micron WASM update: could not fetch release metadata (${msg})`);
|
||||
}
|
||||
if (!sumsRes.ok) {
|
||||
const detail = await readFetchErrorDetail(sumsRes);
|
||||
throw new Error(`Micron WASM update: SHASUMS256 fetch failed (${detail}). Check the tag and your network.`);
|
||||
}
|
||||
let sumsText;
|
||||
try {
|
||||
sumsText = await sumsRes.text();
|
||||
} catch (e) {
|
||||
throw new Error(`Micron WASM update: could not read SHASUMS256 (${e && e.message})`);
|
||||
}
|
||||
const expectedHex = parseShasums256ForFilename(sumsText, WASM_FILENAME);
|
||||
if (!expectedHex) {
|
||||
throw new Error(`Micron WASM update: ${WASM_FILENAME} not listed in ${SHASUMS256_FILENAME} for this release.`);
|
||||
}
|
||||
let wasmRes;
|
||||
try {
|
||||
wasmRes = await fetch(micronParserGoReleaseProxyUrl(t, WASM_FILENAME), { signal, cache: "no-store" });
|
||||
} catch (e) {
|
||||
const msg = e && e.name === "AbortError" ? "Request aborted" : (e && e.message) || String(e);
|
||||
throw new Error(`Micron WASM update: could not download WASM (${msg})`);
|
||||
}
|
||||
if (!wasmRes.ok) {
|
||||
const detail = await readFetchErrorDetail(wasmRes);
|
||||
throw new Error(`Micron WASM update: WASM download failed (${detail})`);
|
||||
}
|
||||
let buf;
|
||||
try {
|
||||
buf = await wasmRes.arrayBuffer();
|
||||
} catch (e) {
|
||||
throw new Error(`Micron WASM update: could not read WASM body (${e && e.message})`);
|
||||
}
|
||||
if (buf.byteLength > MAX_WASM_OVERRIDE_BYTES) {
|
||||
throw new Error(`Micron WASM update: WASM exceeds maximum size (${MAX_WASM_OVERRIDE_BYTES} bytes).`);
|
||||
}
|
||||
if (buf.byteLength < 4096) {
|
||||
throw new Error("Micron WASM update: WASM file is too small to be valid.");
|
||||
}
|
||||
const actualHex = await sha256HexOfBuffer(buf);
|
||||
if (actualHex !== expectedHex) {
|
||||
throw new Error(
|
||||
"Micron WASM update: SHA-256 mismatch after download. Refusing to install (possible tampering or corrupt transfer)."
|
||||
);
|
||||
}
|
||||
const wasmSri = await computeWasmSriSha384(buf);
|
||||
return {
|
||||
source: "github",
|
||||
releaseTag: t,
|
||||
wasmSri,
|
||||
wasmBytes: buf,
|
||||
expectedSha256Hex: expectedHex,
|
||||
};
|
||||
}
|
||||
|
||||
function assertSri(wasmSri) {
|
||||
if (typeof wasmSri !== "string" || !/^sha384-[A-Za-z0-9+/=]+$/.test(wasmSri)) {
|
||||
throw new Error("Micron WASM update: invalid SRI format");
|
||||
|
|
@ -199,8 +67,7 @@ export async function setMicronWasmRuntimeOverride(record) {
|
|||
throw new Error(`Micron WASM update: WASM exceeds maximum size (${MAX_WASM_OVERRIDE_BYTES} bytes).`);
|
||||
}
|
||||
assertSri(record.wasmSri);
|
||||
const source = record.source === "upload" ? "upload" : "github";
|
||||
const releaseTag = String(record.releaseTag || "").trim() || (source === "github" ? "unknown" : "upload");
|
||||
const releaseTag = String(record.releaseTag || "").trim() || "upload";
|
||||
const expectedSha256Hex = record.expectedSha256Hex == null ? null : String(record.expectedSha256Hex).toLowerCase();
|
||||
const db = await openDb();
|
||||
try {
|
||||
|
|
@ -208,7 +75,7 @@ export async function setMicronWasmRuntimeOverride(record) {
|
|||
const st = tx.objectStore(STORE);
|
||||
st.put(
|
||||
{
|
||||
source,
|
||||
source: "upload",
|
||||
releaseTag,
|
||||
wasmSri: record.wasmSri,
|
||||
wasmBytes: buf,
|
||||
|
|
@ -241,7 +108,7 @@ export async function getMicronWasmRuntimeOverride() {
|
|||
return null;
|
||||
}
|
||||
return {
|
||||
source: raw.source === "upload" ? "upload" : "github",
|
||||
source: "upload",
|
||||
releaseTag: String(raw.releaseTag || ""),
|
||||
wasmSri: String(raw.wasmSri),
|
||||
wasmBytes: raw.wasmBytes,
|
||||
|
|
|
|||
47
meshchatx/src/frontend/js/lanBindWarning.js
Normal file
47
meshchatx/src/frontend/js/lanBindWarning.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
/**
|
||||
* Browser-only LAN bind warning. Electron and Android bind locally and
|
||||
* never show this. Headless LAN binds keep running. The UI warns instead.
|
||||
*/
|
||||
|
||||
export const LAN_BIND_NO_AUTH_BANNER_DISMISSED_KEY = "meshchatx_lan_bind_no_auth_banner_dismissed";
|
||||
|
||||
export function isLanBindNoAuthBannerDismissed() {
|
||||
try {
|
||||
return localStorage.getItem(LAN_BIND_NO_AUTH_BANNER_DISMISSED_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function dismissLanBindNoAuthBanner() {
|
||||
try {
|
||||
localStorage.setItem(LAN_BIND_NO_AUTH_BANNER_DISMISSED_KEY, "1");
|
||||
} catch {
|
||||
/* ignore storage failures */
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldShowLanBindNoAuthBanner({
|
||||
isElectron = false,
|
||||
isAndroid = false,
|
||||
authEnabled = false,
|
||||
isLoopbackBind = true,
|
||||
routeName = "",
|
||||
dismissed = false,
|
||||
} = {}) {
|
||||
if (dismissed || isLanBindNoAuthBannerDismissed()) {
|
||||
return false;
|
||||
}
|
||||
if (isElectron || isAndroid) {
|
||||
return false;
|
||||
}
|
||||
if (routeName === "auth") {
|
||||
return false;
|
||||
}
|
||||
if (authEnabled) {
|
||||
return false;
|
||||
}
|
||||
return isLoopbackBind === false;
|
||||
}
|
||||
|
|
@ -391,6 +391,9 @@ if (networkReady) {
|
|||
try {
|
||||
const statusResponse = await window.api.get("/api/v1/status");
|
||||
GlobalState.demoMode = !!statusResponse.data?.demo_mode;
|
||||
if (typeof statusResponse.data?.is_loopback_bind === "boolean") {
|
||||
GlobalState.isLoopbackBind = statusResponse.data.is_loopback_bind;
|
||||
}
|
||||
} catch {
|
||||
// status optional during early boot
|
||||
}
|
||||
|
|
@ -407,6 +410,9 @@ if (networkReady) {
|
|||
GlobalState.authEnabled = !!status.auth_enabled;
|
||||
GlobalState.authenticated = !!status.authenticated;
|
||||
GlobalState.demoMode = !!status.demo_mode;
|
||||
if (typeof status.is_loopback_bind === "boolean") {
|
||||
GlobalState.isLoopbackBind = status.is_loopback_bind;
|
||||
}
|
||||
GlobalState.authSessionResolved = true;
|
||||
|
||||
if (!status.auth_enabled) {
|
||||
|
|
|
|||
|
|
@ -120,10 +120,6 @@
|
|||
"method": "GET",
|
||||
"path": "/api/v1/community-interfaces"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/community-interfaces/refresh"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/comports"
|
||||
|
|
@ -996,10 +992,6 @@
|
|||
"method": "GET",
|
||||
"path": "/api/v1/repository-server/list"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/repository-server/refresh-bundled"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/repository-server/status"
|
||||
|
|
@ -1708,18 +1700,6 @@
|
|||
"method": "POST",
|
||||
"path": "/api/v1/telephone/voicemails/{id}/read"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/tools/micron-parser-go-release"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/tools/rnode/download_firmware"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/tools/rnode/latest_release"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/translator/install-languages"
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ describe("electron/mainHelpers", () => {
|
|||
expect(
|
||||
isTrustedIpcEvent({
|
||||
sender: { getURL: () => "file:///opt/meshchatx/electron/loading.html" },
|
||||
}),
|
||||
})
|
||||
).toBe(true);
|
||||
expect(isTrustedIpcEvent({})).toBe(false);
|
||||
expect(isTrustedIpcEvent(null)).toBe(false);
|
||||
|
|
|
|||
41
tests/frontend/AppShellBanners.test.js
Normal file
41
tests/frontend/AppShellBanners.test.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import AppShellBanners from "@/components/layout/AppShellBanners.vue";
|
||||
|
||||
describe("AppShellBanners LAN bind warning", () => {
|
||||
it("shows the LAN banner, emits open-settings, and dismiss", async () => {
|
||||
const wrapper = mount(AppShellBanners, {
|
||||
props: {
|
||||
showLanBindNoAuth: true,
|
||||
lanBindNoAuthLabel: "LAN bind without a password",
|
||||
openSettingsLabel: "Open settings",
|
||||
dismissLanBindNoAuthLabel: "Dismiss",
|
||||
},
|
||||
});
|
||||
expect(wrapper.text()).toContain("LAN bind without a password");
|
||||
const settingsButton = wrapper.findAll("button").find((b) => b.text() === "Open settings");
|
||||
expect(settingsButton).toBeTruthy();
|
||||
await settingsButton.trigger("click");
|
||||
expect(wrapper.emitted("open-settings")).toHaveLength(1);
|
||||
const dismissButton = wrapper.findAll("button").find((b) => b.text() === "Dismiss");
|
||||
expect(dismissButton).toBeTruthy();
|
||||
await dismissButton.trigger("click");
|
||||
expect(wrapper.emitted("dismiss-lan-bind-no-auth")).toHaveLength(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("hides the LAN banner when the prop is off", () => {
|
||||
const wrapper = mount(AppShellBanners, {
|
||||
props: {
|
||||
showLanBindNoAuth: false,
|
||||
lanBindNoAuthLabel: "LAN bind without a password",
|
||||
openSettingsLabel: "Open settings",
|
||||
dismissLanBindNoAuthLabel: "Dismiss",
|
||||
},
|
||||
});
|
||||
expect(wrapper.text()).not.toContain("LAN bind without a password");
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,131 +1,37 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
WASM_FILENAME,
|
||||
clearMicronWasmRuntimeOverride,
|
||||
computeWasmSriSha384,
|
||||
fetchWasmFromGitHubReleaseVerified,
|
||||
getMicronWasmRuntimeOverride,
|
||||
parseShasums256ForFilename,
|
||||
setMicronWasmRuntimeOverride,
|
||||
sha256HexOfBuffer,
|
||||
} from "../../meshchatx/src/frontend/js/MicronWasmRuntimeOverride.js";
|
||||
import {
|
||||
invalidateNomadMicronWasmPreload,
|
||||
refreshMicronWasmRuntimeOverrideCache,
|
||||
} from "../../meshchatx/src/frontend/js/MicronWasmLoader.js";
|
||||
import { refreshMicronWasmRuntimeOverrideCache } from "../../meshchatx/src/frontend/js/MicronWasmLoader.js";
|
||||
|
||||
describe("MicronWasmRuntimeOverride.js", () => {
|
||||
beforeEach(async () => {
|
||||
await clearMicronWasmRuntimeOverride();
|
||||
refreshMicronWasmRuntimeOverrideCache();
|
||||
invalidateNomadMicronWasmPreload();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await clearMicronWasmRuntimeOverride();
|
||||
refreshMicronWasmRuntimeOverrideCache();
|
||||
invalidateNomadMicronWasmPreload();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("parseShasums256ForFilename reads hex line", () => {
|
||||
const text = "c2b0f7de7f241719b54f99b12dc8215e262bdbecb8a5b2a25de3848408aa44cb micron-parser-go.wasm\n";
|
||||
expect(parseShasums256ForFilename(text, WASM_FILENAME)).toBe(
|
||||
"c2b0f7de7f241719b54f99b12dc8215e262bdbecb8a5b2a25de3848408aa44cb"
|
||||
);
|
||||
});
|
||||
|
||||
it("parseShasums256ForFilename supports BSD asterisk prefix", () => {
|
||||
const text = "aa".repeat(32) + " *micron-parser-go.wasm\n";
|
||||
expect(parseShasums256ForFilename(text, WASM_FILENAME)).toBe("aa".repeat(32));
|
||||
});
|
||||
|
||||
it("parseShasums256ForFilename returns null when filename missing", () => {
|
||||
expect(parseShasums256ForFilename("abcd".repeat(16) + " other.bin\n", WASM_FILENAME)).toBe(null);
|
||||
});
|
||||
|
||||
it("setMicronWasmRuntimeOverride round-trips via IndexedDB", async () => {
|
||||
const wasmBytes = new Uint8Array(5000).fill(7).buffer;
|
||||
const wasmBytes = new Uint8Array(4096).fill(7).buffer;
|
||||
const wasmSri = await computeWasmSriSha384(wasmBytes);
|
||||
await setMicronWasmRuntimeOverride({
|
||||
source: "upload",
|
||||
releaseTag: "test.wasm",
|
||||
releaseTag: "custom.wasm",
|
||||
wasmSri,
|
||||
wasmBytes,
|
||||
expectedSha256Hex: null,
|
||||
});
|
||||
const got = await getMicronWasmRuntimeOverride();
|
||||
expect(got).not.toBeNull();
|
||||
expect(got.source).toBe("upload");
|
||||
expect(got.releaseTag).toBe("test.wasm");
|
||||
expect(got.releaseTag).toBe("custom.wasm");
|
||||
expect(got.wasmSri).toBe(wasmSri);
|
||||
expect(got.wasmBytes.byteLength).toBe(5000);
|
||||
});
|
||||
|
||||
it("fetchWasmFromGitHubReleaseVerified rejects hash mismatch", async () => {
|
||||
const wasmBytes = new Uint8Array(5000).fill(9).buffer;
|
||||
const expectedHex = await sha256HexOfBuffer(wasmBytes);
|
||||
const wrongHex = "0".repeat(64);
|
||||
const sums = `${wrongHex} ${WASM_FILENAME}\n`;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((url) => {
|
||||
const u = String(url);
|
||||
if (u.includes("micron-parser-go-release") && u.includes("asset=SHASUMS256.txt")) {
|
||||
return Promise.resolve(new Response(sums, { status: 200 }));
|
||||
}
|
||||
if (u.includes("micron-parser-go-release") && u.includes("asset=micron-parser-go.wasm")) {
|
||||
return Promise.resolve(new Response(wasmBytes, { status: 200 }));
|
||||
}
|
||||
return Promise.resolve(new Response("", { status: 404 }));
|
||||
})
|
||||
);
|
||||
await expect(fetchWasmFromGitHubReleaseVerified("v9.9.9")).rejects.toThrow(/SHA-256 mismatch/);
|
||||
});
|
||||
|
||||
it("fetchWasmFromGitHubReleaseVerified succeeds when SHASUMS matches WASM", async () => {
|
||||
const wasmBytes = new Uint8Array(5000).fill(3).buffer;
|
||||
const expectedHex = await sha256HexOfBuffer(wasmBytes);
|
||||
const sums = `${expectedHex} ${WASM_FILENAME}\n`;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((url) => {
|
||||
const u = String(url);
|
||||
if (u.includes("micron-parser-go-release") && u.includes("asset=SHASUMS256.txt")) {
|
||||
return Promise.resolve(new Response(sums, { status: 200 }));
|
||||
}
|
||||
if (u.includes("micron-parser-go-release") && u.includes("asset=micron-parser-go.wasm")) {
|
||||
return Promise.resolve(new Response(wasmBytes, { status: 200 }));
|
||||
}
|
||||
return Promise.resolve(new Response("", { status: 404 }));
|
||||
})
|
||||
);
|
||||
const rec = await fetchWasmFromGitHubReleaseVerified("v1.0.0");
|
||||
expect(rec.source).toBe("github");
|
||||
expect(rec.releaseTag).toBe("v1.0.0");
|
||||
expect(rec.expectedSha256Hex).toBe(expectedHex);
|
||||
expect(rec.wasmBytes.byteLength).toBe(5000);
|
||||
expect(rec.wasmSri).toMatch(/^sha384-/);
|
||||
});
|
||||
|
||||
it("fetchWasmFromGitHubReleaseVerified maps network failure to readable error", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.reject(new TypeError("Failed to fetch")))
|
||||
);
|
||||
await expect(fetchWasmFromGitHubReleaseVerified("v1.0.0")).rejects.toThrow(/could not fetch release metadata/i);
|
||||
});
|
||||
|
||||
it("fetchWasmFromGitHubReleaseVerified handles missing SHASUMS line", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((url) => {
|
||||
if (String(url).includes("asset=SHASUMS256.txt")) {
|
||||
return Promise.resolve(new Response("deadbeef\n", { status: 200 }));
|
||||
}
|
||||
return Promise.resolve(new Response("", { status: 404 }));
|
||||
})
|
||||
);
|
||||
await expect(fetchWasmFromGitHubReleaseVerified("v1.0.0")).rejects.toThrow(/not listed/);
|
||||
expect(got.wasmBytes.byteLength).toBe(4096);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -990,5 +990,34 @@ describe("NomadNetworkPage.vue", () => {
|
|||
expect(ToastUtils.error).toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("oversized page failure toasts failed_to_load_page instead of hanging", async () => {
|
||||
const wrapper = mountNomadNetworkPage({
|
||||
destinationHash: "",
|
||||
embedded: true,
|
||||
isActive: true,
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
wrapper.vm.isLoadingNodePage = true;
|
||||
wrapper.vm.currentPageDownloadId = 4243;
|
||||
wrapper.vm.nodePagePath = `${"c".repeat(32)}:/page/index.mu`;
|
||||
|
||||
await wrapper.vm.onWebsocketMessage({
|
||||
data: JSON.stringify({
|
||||
type: "nomadnet.page.download",
|
||||
download_id: 4243,
|
||||
nomadnet_page_download: {
|
||||
status: "failure",
|
||||
destination_hash: "",
|
||||
page_path: "",
|
||||
failure_reason: "page_too_large",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(wrapper.vm.isLoadingNodePage).toBe(false);
|
||||
expect(ToastUtils.error).toHaveBeenCalledWith("nomadnet.failed_to_load_page");
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,26 +25,6 @@ describe("RNodeFlasherPage.vue", () => {
|
|||
toastSuccess.mockClear();
|
||||
toastInfo.mockClear();
|
||||
toastWarning.mockClear();
|
||||
|
||||
window.fetch = vi.fn().mockImplementation((url) => {
|
||||
if (typeof url === "string" && url.includes("/api/v1/tools/rnode/latest_release")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
tag_name: "v1.0",
|
||||
assets: [
|
||||
{
|
||||
name: "firmware.zip",
|
||||
browser_download_url:
|
||||
"https://github.com/markqvist/RNode_Firmware/releases/download/v1/firmware.zip",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -77,20 +57,6 @@ describe("RNodeFlasherPage.vue", () => {
|
|||
expect(wrapper.text()).toContain("1. tools.rnode_flasher.select_device");
|
||||
});
|
||||
|
||||
it("requests latest_release without a repo query (GitHub default on server)", async () => {
|
||||
mountRNodeFlasherPage();
|
||||
await vi.waitFor(() => {
|
||||
expect(window.fetch).toHaveBeenCalled();
|
||||
});
|
||||
const releaseCalls = window.fetch.mock.calls.filter(
|
||||
(c) => typeof c[0] === "string" && c[0].includes("latest_release")
|
||||
);
|
||||
expect(releaseCalls.length).toBeGreaterThanOrEqual(1);
|
||||
const u = releaseCalls[0][0];
|
||||
expect(u).toBe("/api/v1/tools/rnode/latest_release");
|
||||
expect(u).not.toContain("?");
|
||||
});
|
||||
|
||||
it("toggles advanced mode", async () => {
|
||||
const wrapper = mountRNodeFlasherPage();
|
||||
expect(wrapper.vm.showAdvanced).toBe(false);
|
||||
|
|
@ -119,146 +85,10 @@ describe("RNodeFlasherPage.vue", () => {
|
|||
expect(options.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("resolves recommended asset url from the release when present", () => {
|
||||
const wrapper = mountRNodeFlasherPage();
|
||||
wrapper.vm.selectedProduct = { firmware_filename: "firmware.zip" };
|
||||
wrapper.vm.latestRelease = {
|
||||
assets: [{ name: "firmware.zip", browser_download_url: "https://gitea/example.zip" }],
|
||||
};
|
||||
expect(wrapper.vm._resolveRecommendedAssetUrl()).toBe("https://gitea/example.zip");
|
||||
});
|
||||
|
||||
it("falls back to the GitHub releases/latest/download URL when the release lookup failed", () => {
|
||||
const wrapper = mountRNodeFlasherPage();
|
||||
wrapper.vm.selectedProduct = { firmware_filename: "rnode_firmware_heltec32v3.zip" };
|
||||
wrapper.vm.latestRelease = null;
|
||||
const url = wrapper.vm._resolveRecommendedAssetUrl();
|
||||
expect(url).toBe(
|
||||
"https://github.com/markqvist/RNode_Firmware/releases/latest/download/rnode_firmware_heltec32v3.zip"
|
||||
);
|
||||
});
|
||||
|
||||
it("uses model firmware_filename when present for fallback URL", () => {
|
||||
const wrapper = mountRNodeFlasherPage();
|
||||
wrapper.vm.selectedProduct = { models: [] };
|
||||
wrapper.vm.selectedModel = { firmware_filename: "rnode_firmware_tbeam.zip" };
|
||||
wrapper.vm.latestRelease = null;
|
||||
expect(wrapper.vm._resolveRecommendedAssetUrl()).toBe(
|
||||
"https://github.com/markqvist/RNode_Firmware/releases/latest/download/rnode_firmware_tbeam.zip"
|
||||
);
|
||||
});
|
||||
|
||||
it("links footer firmware and flasher pages to GitHub", () => {
|
||||
const wrapper = mountRNodeFlasherPage();
|
||||
const html = wrapper.html();
|
||||
expect(html).toContain('href="https://github.com/markqvist/RNode_Firmware"');
|
||||
expect(html).toContain('href="https://github.com/liamcottle/rnode-flasher"');
|
||||
});
|
||||
|
||||
it("downloadRecommendedFirmware requests proxied download with encoded GitHub URL", async () => {
|
||||
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x00]);
|
||||
const assetUrl = "https://github.com/markqvist/RNode_Firmware/releases/download/v1.0/firmware.zip";
|
||||
|
||||
window.fetch = vi.fn().mockImplementation((url) => {
|
||||
if (typeof url === "string" && url.includes("/api/v1/tools/rnode/latest_release")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
tag_name: "v1.0",
|
||||
assets: [{ name: "firmware.zip", browser_download_url: assetUrl }],
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (typeof url === "string" && url.includes("/api/v1/tools/rnode/download_firmware")) {
|
||||
expect(url).toContain(encodeURIComponent(assetUrl));
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
blob: () => Promise.resolve(new Blob([zipBytes], { type: "application/zip" })),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
|
||||
});
|
||||
|
||||
const wrapper = mount(RNodeFlasherPage, {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key, params) => key + (params ? JSON.stringify(params) : ""),
|
||||
$router: { push: vi.fn() },
|
||||
},
|
||||
stubs: {
|
||||
MaterialDesignIcon: {
|
||||
template: '<div class="mdi-stub" :data-icon-name="iconName"></div>',
|
||||
props: ["iconName"],
|
||||
},
|
||||
"v-icon": true,
|
||||
"v-progress-circular": true,
|
||||
"v-progress-linear": true,
|
||||
RNodeFirmwareSelector: {
|
||||
name: "RNodeFirmwareSelectorStub",
|
||||
template: "<div />",
|
||||
methods: { setFile: vi.fn() },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(wrapper.vm.latestRelease).not.toBeNull());
|
||||
wrapper.vm.selectedProduct = { firmware_filename: "firmware.zip" };
|
||||
|
||||
await wrapper.vm.downloadRecommendedFirmware();
|
||||
await flushPromises();
|
||||
|
||||
expect(toastSuccess).toHaveBeenCalledWith("tools.rnode_flasher.alerts.firmware_downloaded");
|
||||
expect(wrapper.vm.firmwareFile).not.toBe(null);
|
||||
expect(wrapper.vm.firmwareFile.name).toBe("firmware.zip");
|
||||
});
|
||||
|
||||
it("downloadRecommendedFirmware shows error when no firmware filename", async () => {
|
||||
const wrapper = mountRNodeFlasherPage();
|
||||
wrapper.vm.selectedProduct = null;
|
||||
wrapper.vm.selectedModel = null;
|
||||
await wrapper.vm.downloadRecommendedFirmware();
|
||||
expect(toastError).toHaveBeenCalledWith("tools.rnode_flasher.errors.firmware_not_found_in_release");
|
||||
});
|
||||
|
||||
it("downloadRecommendedFirmware shows error when download fails", async () => {
|
||||
window.fetch = vi.fn().mockImplementation((url) => {
|
||||
if (typeof url === "string" && url.includes("/api/v1/tools/rnode/latest_release")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
tag_name: "v1.0",
|
||||
assets: [
|
||||
{
|
||||
name: "firmware.zip",
|
||||
browser_download_url:
|
||||
"https://github.com/markqvist/RNode_Firmware/releases/download/v1/firmware.zip",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (typeof url === "string" && url.includes("/api/v1/tools/rnode/download_firmware")) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 502,
|
||||
statusText: "Bad Gateway",
|
||||
json: () => Promise.resolve({ error: "upstream broke" }),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
|
||||
});
|
||||
|
||||
const wrapper = mountRNodeFlasherPage();
|
||||
await vi.waitFor(() => expect(wrapper.vm.latestRelease).not.toBeNull());
|
||||
wrapper.vm.selectedProduct = { firmware_filename: "firmware.zip" };
|
||||
|
||||
await wrapper.vm.downloadRecommendedFirmware();
|
||||
|
||||
expect(toastError).toHaveBeenCalled();
|
||||
const msg = toastError.mock.calls[0][0];
|
||||
expect(msg).toContain("tools.rnode_flasher.errors.failed_download");
|
||||
expect(msg).toContain("upstream broke");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { readFileSync } from "fs";
|
||||
import { readFileSync, readdirSync, statSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
|
|
@ -484,3 +484,136 @@ describe("behavior contracts: locale, theme, and call audio", () => {
|
|||
expect(call).toMatch(/requestAudioPermission[\s\S]*promptMicrophoneAccess[\s\S]*refreshAudioDevices/);
|
||||
});
|
||||
});
|
||||
|
||||
function listVueFiles(dir) {
|
||||
const out = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) {
|
||||
out.push(...listVueFiles(p));
|
||||
} else if (name.endsWith(".vue")) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const VHTML_SANITIZER_TOKENS = [
|
||||
"renderMarkdown",
|
||||
"renderMessageHtml",
|
||||
"sanitizeNomadHtml",
|
||||
"renderNomadPageByPath",
|
||||
"renderNomadHtmlPage",
|
||||
"renderNomadMarkdown",
|
||||
"convertMicronToHtml",
|
||||
"sanitizeRenderedMicronHtml",
|
||||
"drawFeatureDescriptionSanitized",
|
||||
"highlightMatch",
|
||||
"changelogHtml",
|
||||
"selectedDocContent.html",
|
||||
"$t(",
|
||||
"MarkdownRenderer",
|
||||
];
|
||||
|
||||
describe("behavior contracts: security gates", () => {
|
||||
it("WebSocket Origin check is wired on both upgrade paths", () => {
|
||||
const src = readSource("meshchatx/src/backend/http/routes/websocket_upgrade.py");
|
||||
expect(src).toContain("websocket_origin_allowed");
|
||||
expect(src).toContain("_reject_forbidden_ws_origin");
|
||||
expect(src).toContain('{"error": "Forbidden origin"}');
|
||||
});
|
||||
|
||||
it("WebSocket auth fails closed except ping", () => {
|
||||
const src = readSource("meshchatx/src/backend/websocket_config_guard.py");
|
||||
const match = src.match(/WEBSOCKET_PUBLIC_TYPES = frozenset\(\s*\{([^}]+)\}/s);
|
||||
expect(match).toBeTruthy();
|
||||
const members = [...match[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
|
||||
expect(members).toEqual(["ping"]);
|
||||
expect(src).toContain("websocket_type_requires_auth");
|
||||
expect(src).toContain("if msg_type in WEBSOCKET_PUBLIC_TYPES:");
|
||||
});
|
||||
|
||||
it("FileSync reserved tops include ssl", () => {
|
||||
const src = readSource("meshchatx/src/backend/rns_filesync_handler.py");
|
||||
const start = src.indexOf("_RESERVED_SYNC_TOP");
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const block = src.slice(start, src.indexOf(")", start) + 1);
|
||||
expect(block).toContain('"ssl"');
|
||||
});
|
||||
|
||||
it("plugin invoke and hooks re-hash backends and purge pycache", () => {
|
||||
const manager = readSource("meshchatx/src/backend/plugin_manager.py");
|
||||
const invokeStart = manager.indexOf("def invoke(");
|
||||
const invokeBlock = manager.slice(invokeStart, manager.indexOf("def dispatch_hook(", invokeStart));
|
||||
expect(invokeBlock).toContain("self._require_untampered_backend(record)");
|
||||
const hookStart = manager.indexOf("def dispatch_hook(");
|
||||
const hookBlock = manager.slice(hookStart, hookStart + 800);
|
||||
expect(hookBlock).toContain("self._require_untampered_backend(record)");
|
||||
const runtime = readSource("meshchatx/src/backend/plugin_python_runtime.py");
|
||||
expect(runtime).toContain("def _purge_entry_pycache");
|
||||
expect(runtime).toContain("self._purge_entry_pycache(entry_path)");
|
||||
});
|
||||
|
||||
it("plugin network scan parses hostname instead of prefix-matching loopback", () => {
|
||||
const src = readSource("meshchatx/src/backend/plugin_permissions.py");
|
||||
const start = src.indexOf("def _is_external_http_url");
|
||||
const block = src.slice(start, src.indexOf("\ndef ", start + 1));
|
||||
expect(block).toContain("urlparse(value)");
|
||||
expect(block).toContain("parsed.hostname");
|
||||
expect(block).not.toMatch(/"127\.0\.0\.1" in /);
|
||||
expect(block).not.toMatch(/"localhost" in /);
|
||||
});
|
||||
|
||||
it("Electron ipcMain.handle is wrapped by trustedIpcHandle", () => {
|
||||
const main = readSource("electron/main.js");
|
||||
expect(main).toContain("function trustedIpcHandle");
|
||||
expect(main).toContain("isTrustedIpcEvent");
|
||||
expect(main).not.toMatch(/ipcMain\.handle\("/);
|
||||
});
|
||||
|
||||
it("v-html sites name a sanitizer and do not use a bare file-level disable", () => {
|
||||
const vueRoot = join(process.cwd(), "meshchatx/src/frontend/components");
|
||||
const files = listVueFiles(vueRoot);
|
||||
const withVHtml = [];
|
||||
for (const abs of files) {
|
||||
const src = readFileSync(abs, "utf8");
|
||||
if (!src.includes("v-html")) {
|
||||
continue;
|
||||
}
|
||||
withVHtml.push(abs);
|
||||
expect(src, abs).not.toMatch(/eslint-disable\s+vue\/no-v-html\s*-->/);
|
||||
const named = VHTML_SANITIZER_TOKENS.some((token) => src.includes(token));
|
||||
expect(named, abs).toBe(true);
|
||||
}
|
||||
expect(withVHtml.length).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
it("LAN bind warning is a banner, not a process exit", () => {
|
||||
const app = readSource("meshchatx/src/frontend/components/App.vue");
|
||||
expect(app).toContain("showLanBindNoAuthBanner");
|
||||
expect(app).toContain("shouldShowLanBindNoAuthBanner");
|
||||
expect(app).toContain("lan_bind_no_auth_banner");
|
||||
const banners = readSource("meshchatx/src/frontend/components/layout/AppShellBanners.vue");
|
||||
expect(banners).toContain("showLanBindNoAuth");
|
||||
expect(banners).toContain("lanBindNoAuthLabel");
|
||||
const helper = readSource("meshchatx/src/frontend/js/lanBindWarning.js");
|
||||
expect(helper).toContain("isElectron");
|
||||
expect(helper).toContain("isAndroid");
|
||||
expect(helper).toContain("dismissLanBindNoAuthBanner");
|
||||
expect(helper).toContain("isLanBindNoAuthBannerDismissed");
|
||||
expect(helper).not.toContain("process.exit");
|
||||
expect(helper).not.toContain("sys.exit");
|
||||
});
|
||||
|
||||
it("mesh payload caps stay named constants with drop-not-hang reasons", () => {
|
||||
const announce = readSource("meshchatx/src/backend/announce_manager.py");
|
||||
expect(announce).toContain("MAX_ANNOUNCE_APP_DATA_BYTES = 2048");
|
||||
const nomad = readSource("meshchatx/src/backend/nomadnet_downloader.py");
|
||||
expect(nomad).toContain("MAX_NOMAD_PAGE_BYTES = 512 * 1024");
|
||||
expect(nomad).toContain("page_too_large");
|
||||
const rrc = readSource("meshchatx/src/backend/rrc/protocol.py");
|
||||
expect(rrc).toContain("DEFAULT_MAX_MSG_BYTES = 350");
|
||||
const geo = readSource("meshchatx/src/backend/map_geo_validator.py");
|
||||
expect(geo).toContain('raise GeoValidationError("file_too_large")');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
47
tests/frontend/lanBindWarning.test.js
Normal file
47
tests/frontend/lanBindWarning.test.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
dismissLanBindNoAuthBanner,
|
||||
isLanBindNoAuthBannerDismissed,
|
||||
LAN_BIND_NO_AUTH_BANNER_DISMISSED_KEY,
|
||||
shouldShowLanBindNoAuthBanner,
|
||||
} from "@/js/lanBindWarning.js";
|
||||
|
||||
describe("shouldShowLanBindNoAuthBanner", () => {
|
||||
const lanNoAuth = {
|
||||
isElectron: false,
|
||||
isAndroid: false,
|
||||
authEnabled: false,
|
||||
isLoopbackBind: false,
|
||||
routeName: "messages",
|
||||
dismissed: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(LAN_BIND_NO_AUTH_BANNER_DISMISSED_KEY);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem(LAN_BIND_NO_AUTH_BANNER_DISMISSED_KEY);
|
||||
});
|
||||
|
||||
it("shows for browser LAN bind without auth", () => {
|
||||
expect(shouldShowLanBindNoAuthBanner(lanNoAuth)).toBe(true);
|
||||
});
|
||||
|
||||
it("hides on electron, android, loopback, auth, or auth route", () => {
|
||||
expect(shouldShowLanBindNoAuthBanner({ ...lanNoAuth, isElectron: true })).toBe(false);
|
||||
expect(shouldShowLanBindNoAuthBanner({ ...lanNoAuth, isAndroid: true })).toBe(false);
|
||||
expect(shouldShowLanBindNoAuthBanner({ ...lanNoAuth, isLoopbackBind: true })).toBe(false);
|
||||
expect(shouldShowLanBindNoAuthBanner({ ...lanNoAuth, authEnabled: true })).toBe(false);
|
||||
expect(shouldShowLanBindNoAuthBanner({ ...lanNoAuth, routeName: "auth" })).toBe(false);
|
||||
});
|
||||
|
||||
it("stays hidden after dismiss is persisted", () => {
|
||||
dismissLanBindNoAuthBanner();
|
||||
expect(isLanBindNoAuthBannerDismissed()).toBe(true);
|
||||
expect(shouldShowLanBindNoAuthBanner(lanNoAuth)).toBe(false);
|
||||
expect(shouldShowLanBindNoAuthBanner({ ...lanNoAuth, dismissed: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
118
tests/frontend/sanitizerXssOracle.test.js
Normal file
118
tests/frontend/sanitizerXssOracle.test.js
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// SPDX-License-Identifier: 0BSD
|
||||
|
||||
/**
|
||||
* Shared XSS corpus against every HTML sanitizer the UI ships.
|
||||
* Formatted markup may remain. Scriptable nodes and executable URLs must not.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import MarkdownRenderer from "@/js/MarkdownRenderer.js";
|
||||
import MicronParser from "@/js/MicronParser.js";
|
||||
import {
|
||||
renderNomadHtmlPage,
|
||||
renderNomadMarkdown,
|
||||
sanitizeNomadHtmlDocument,
|
||||
sanitizeNomadHtmlFragment,
|
||||
} from "@/js/NomadPageRenderer.js";
|
||||
import { sanitizeKmlText } from "@/js/mapExchange/kmlSanitize.js";
|
||||
|
||||
export const XSS_PAYLOADS = [
|
||||
{ name: "javascript href", input: '<a href="javascript:alert(1)">x</a>' },
|
||||
{ name: "javascript uppercase", input: '<a href="JAVASCRIPT:alert(1)">x</a>' },
|
||||
{ name: "data html href", input: '<a href="data:text/html,<script>alert(1)</script>">x</a>' },
|
||||
{ name: "base tag", input: '<base href="https://evil.example/"><p>ok</p>' },
|
||||
{ name: "svg script", input: "<svg><script>alert(1)</script></svg><p>ok</p>" },
|
||||
{ name: "svg onload", input: '<svg onload="alert(1)"></svg><p>ok</p>' },
|
||||
{ name: "img onerror", input: '<img src=x onerror="alert(1)"><p>ok</p>' },
|
||||
{
|
||||
name: "css url javascript",
|
||||
input: '<style>body{background:url("javascript:alert(1)")}</style><p>ok</p>',
|
||||
},
|
||||
{
|
||||
name: "css url https",
|
||||
input: '<style>p{background:url("https://evil.example/x.png")}</style><p>ok</p>',
|
||||
},
|
||||
{ name: "nested markdown js", input: "[x](javascript:alert(1))" },
|
||||
{ name: "nested markdown image", input: ")" },
|
||||
{ name: "script tag", input: "<script>alert(1)</script><p>ok</p>" },
|
||||
{ name: "iframe", input: '<iframe src="javascript:alert(1)"></iframe><p>ok</p>' },
|
||||
{ name: "micron js link", input: "`[click`javascript:alert(1)]`" },
|
||||
];
|
||||
|
||||
export function assertNoScriptableHtml(html, payloadName) {
|
||||
expect(typeof html, payloadName).toBe("string");
|
||||
const doc = new DOMParser().parseFromString(`<div id="xss-root">${html}</div>`, "text/html");
|
||||
const root = doc.getElementById("xss-root") || doc.body;
|
||||
expect(root.querySelector("script, iframe, object, embed, base"), payloadName).toBeNull();
|
||||
for (const el of root.querySelectorAll("[href], [src]")) {
|
||||
for (const attr of ["href", "src"]) {
|
||||
const v = el.getAttribute(attr);
|
||||
if (!v) {
|
||||
continue;
|
||||
}
|
||||
const lower = v.trim().toLowerCase();
|
||||
expect(lower.startsWith("javascript:"), `${payloadName} ${attr}`).toBe(false);
|
||||
expect(lower.startsWith("vbscript:"), `${payloadName} ${attr}`).toBe(false);
|
||||
expect(lower.startsWith("data:text/html"), `${payloadName} ${attr}`).toBe(false);
|
||||
}
|
||||
}
|
||||
for (const el of root.querySelectorAll("*")) {
|
||||
for (const attr of [...el.attributes]) {
|
||||
expect(attr.name.toLowerCase().startsWith("on"), `${payloadName} ${attr.name}`).toBe(false);
|
||||
}
|
||||
}
|
||||
for (const styleEl of root.querySelectorAll("style")) {
|
||||
const css = (styleEl.textContent || "").toLowerCase();
|
||||
expect(css, payloadName).not.toMatch(/url\s*\(\s*["']?\s*javascript:/);
|
||||
expect(css, payloadName).not.toMatch(/expression\s*\(/);
|
||||
}
|
||||
for (const el of root.querySelectorAll("[style]")) {
|
||||
const css = (el.getAttribute("style") || "").toLowerCase();
|
||||
expect(css, payloadName).not.toMatch(/url\s*\(\s*["']?\s*javascript:/);
|
||||
}
|
||||
}
|
||||
|
||||
function wrapKml(inner) {
|
||||
return `<?xml version="1.0"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
|
||||
<Placemark><name>P</name>
|
||||
<description><![CDATA[${inner}]]></description>
|
||||
<Point><coordinates>1,2,0</coordinates></Point>
|
||||
</Placemark></Document></kml>`;
|
||||
}
|
||||
|
||||
describe("shared XSS sanitizer oracles", () => {
|
||||
it("MarkdownRenderer never emits a scriptable node", () => {
|
||||
for (const { name, input } of XSS_PAYLOADS) {
|
||||
assertNoScriptableHtml(MarkdownRenderer.render(input), `render ${name}`);
|
||||
assertNoScriptableHtml(MarkdownRenderer.renderBasic(input), `renderBasic ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("Nomad HTML and markdown sanitizers never emit a scriptable node", () => {
|
||||
for (const { name, input } of XSS_PAYLOADS) {
|
||||
assertNoScriptableHtml(sanitizeNomadHtmlFragment(input), `fragment ${name}`);
|
||||
assertNoScriptableHtml(sanitizeNomadHtmlDocument(input), `document ${name}`);
|
||||
assertNoScriptableHtml(renderNomadHtmlPage(input), `html page ${name}`);
|
||||
assertNoScriptableHtml(renderNomadMarkdown(input), `markdown ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("MicronParser never emits a scriptable node", () => {
|
||||
const parser = new MicronParser(true, false);
|
||||
for (const { name, input } of XSS_PAYLOADS) {
|
||||
assertNoScriptableHtml(MicronParser.sanitizeRenderedMicronHtml(input), `sanitize ${name}`);
|
||||
assertNoScriptableHtml(parser.convertMicronToHtml(input), `convert ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("KML sanitizer never emits a scriptable node", () => {
|
||||
for (const { name, input } of XSS_PAYLOADS) {
|
||||
const out = sanitizeKmlText(wrapKml(input));
|
||||
assertNoScriptableHtml(out.text, `kml ${name}`);
|
||||
const lower = out.text.toLowerCase();
|
||||
expect(lower, `kml href ${name}`).not.toMatch(/<href>\s*(javascript:|vbscript:|data:text\/html)/);
|
||||
expect(lower, `kml xlink ${name}`).not.toMatch(/xlink:href\s*=\s*["']?\s*javascript:/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue