diff --git a/extensions/qa-lab/src/lab-server-ui.test.ts b/extensions/qa-lab/src/lab-server-ui.test.ts
index 1d5865fa946..47748e6d261 100644
--- a/extensions/qa-lab/src/lab-server-ui.test.ts
+++ b/extensions/qa-lab/src/lab-server-ui.test.ts
@@ -74,4 +74,18 @@ describe("qa-lab server ui helpers", () => {
expect(tryResolveUiAsset("/", uiDistDir, rootDir)).toBe(path.join(uiDistDir, "index.html"));
expect(tryResolveUiAsset("/../dist-other/secret.txt", uiDistDir, rootDir)).toBeNull();
});
+
+ it("rejects malformed percent-encoded UI asset paths", async () => {
+ const uiDistDir = await mkdtemp(path.join(os.tmpdir(), "qa-lab-ui-malformed-"));
+ cleanups.push(async () => {
+ await rm(uiDistDir, { recursive: true, force: true });
+ });
+ await writeFile(
+ path.join(uiDistDir, "index.html"),
+ "
bundle-root",
+ "utf8",
+ );
+
+ expect(tryResolveUiAsset("/%E0%A4", uiDistDir, uiDistDir)).toBeNull();
+ });
});
diff --git a/extensions/qa-lab/src/lab-server-ui.ts b/extensions/qa-lab/src/lab-server-ui.ts
index 7a4509c6591..53c9d110597 100644
--- a/extensions/qa-lab/src/lab-server-ui.ts
+++ b/extensions/qa-lab/src/lab-server-ui.ts
@@ -269,7 +269,12 @@ export function tryResolveUiAsset(
return null;
}
const safePath = pathname === "/" ? "/index.html" : pathname;
- const decoded = decodeURIComponent(safePath);
+ let decoded: string;
+ try {
+ decoded = decodeURIComponent(safePath);
+ } catch {
+ return null;
+ }
const candidate = path.resolve(distDir, `.${decoded.startsWith("/") ? decoded : `/${decoded}`}`);
const relative = path.relative(distDir, candidate);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
diff --git a/extensions/qa-lab/src/suite-planning.test.ts b/extensions/qa-lab/src/suite-planning.test.ts
index f7c205ef2f7..15ca4a1e7a6 100644
--- a/extensions/qa-lab/src/suite-planning.test.ts
+++ b/extensions/qa-lab/src/suite-planning.test.ts
@@ -179,6 +179,21 @@ describe("qa suite planning helpers", () => {
});
});
+ it("ignores prototype-mutating keys in scenario startup config patches", () => {
+ const scenarios = [
+ makeQaSuiteTestScenario("polluted", {
+ gatewayConfigPatch: JSON.parse(
+ `{"plugins":{"entries":{}},"__proto__":{"polluted":true},"constructor":{"prototype":{"polluted":true}}}`,
+ ) as Record,
+ }),
+ ];
+
+ const patch = collectQaSuiteGatewayConfigPatch(scenarios);
+
+ expect(patch).toEqual({ plugins: { entries: {} } });
+ expect(({} as { polluted?: boolean }).polluted).toBeUndefined();
+ });
+
it("collects gateway runtime options across selected scenarios", () => {
const scenarios = [
makeQaSuiteTestScenario("plain"),
diff --git a/extensions/qa-lab/src/suite-planning.ts b/extensions/qa-lab/src/suite-planning.ts
index 2433593c23d..1872a96c492 100644
--- a/extensions/qa-lab/src/suite-planning.ts
+++ b/extensions/qa-lab/src/suite-planning.ts
@@ -6,6 +6,7 @@ import type { QaTransportId } from "./qa-transport-registry.js";
import { readQaBootstrapScenarioCatalog } from "./scenario-catalog.js";
const DEFAULT_QA_SUITE_CONCURRENCY = 64;
+const QA_MERGE_PATCH_BLOCKED_KEYS = new Set(["__proto__", "constructor", "prototype"]);
type QaSeedScenario = ReturnType["scenarios"][number];
@@ -108,6 +109,9 @@ function applyQaMergePatch(base: unknown, patch: unknown): unknown {
}
const result = isQaPlainObject(base) ? { ...base } : {};
for (const [key, value] of Object.entries(patch)) {
+ if (QA_MERGE_PATCH_BLOCKED_KEYS.has(key)) {
+ continue;
+ }
if (value === null) {
delete result[key];
continue;
diff --git a/extensions/qa-matrix/src/substrate/client.test.ts b/extensions/qa-matrix/src/substrate/client.test.ts
index 142c4caf364..55d49de2eab 100644
--- a/extensions/qa-matrix/src/substrate/client.test.ts
+++ b/extensions/qa-matrix/src/substrate/client.test.ts
@@ -238,8 +238,9 @@ describe("matrix driver client", () => {
expect(requests[0]?.url).toBe(
"http://127.0.0.1:28008/_matrix/media/v3/upload?filename=red-top-blue-bottom.png",
);
- expect(requests[0]?.body instanceof Uint8Array || Buffer.isBuffer(requests[0]?.body)).toBe(
- true,
+ expect(requests[0]?.body).toBeInstanceOf(Uint8Array);
+ expect(Array.from(requests[0]?.body as Uint8Array)).toEqual(
+ Array.from(Buffer.from("png-bytes")),
);
expect(requests[1]?.url).toContain(
"/_matrix/client/v3/rooms/!room%3Amatrix-qa.test/send/m.room.message/",
diff --git a/extensions/qa-matrix/src/substrate/client.ts b/extensions/qa-matrix/src/substrate/client.ts
index 54f1b8c9ad2..92f30aa27d4 100644
--- a/extensions/qa-matrix/src/substrate/client.ts
+++ b/extensions/qa-matrix/src/substrate/client.ts
@@ -263,6 +263,10 @@ async function uploadMatrixQaContent(params: {
if (fileName) {
url.searchParams.set("filename", fileName);
}
+ const uploadBody: Uint8Array =
+ params.buffer.buffer instanceof ArrayBuffer
+ ? new Uint8Array(params.buffer.buffer, params.buffer.byteOffset, params.buffer.byteLength)
+ : Uint8Array.from(params.buffer);
const response = await params.fetchImpl(url, {
method: "POST",
headers: {
@@ -270,7 +274,7 @@ async function uploadMatrixQaContent(params: {
"content-type": params.contentType ?? "application/octet-stream",
...(params.accessToken ? { authorization: `Bearer ${params.accessToken}` } : {}),
},
- body: new Uint8Array(params.buffer),
+ body: uploadBody,
signal: AbortSignal.timeout(20_000),
});
const body = (await response.json().catch(() => ({}))) as {
diff --git a/extensions/qa-matrix/src/substrate/events.test.ts b/extensions/qa-matrix/src/substrate/events.test.ts
index eddd952ff0c..fcbb2513f36 100644
--- a/extensions/qa-matrix/src/substrate/events.test.ts
+++ b/extensions/qa-matrix/src/substrate/events.test.ts
@@ -159,6 +159,27 @@ describe("matrix observed event normalization", () => {
);
});
+ it("treats filename-like Matrix media bodies as attachment filenames", () => {
+ expect(
+ normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
+ event_id: "$image",
+ sender: "@sut:matrix-qa.test",
+ type: "m.room.message",
+ content: {
+ body: "qa-lighthouse.png",
+ msgtype: "m.image",
+ },
+ }),
+ ).toEqual(
+ expect.objectContaining({
+ attachment: {
+ kind: "image",
+ filename: "qa-lighthouse.png",
+ },
+ }),
+ );
+ });
+
it("normalizes membership events with explicit membership kind", () => {
expect(
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
diff --git a/extensions/qa-matrix/src/substrate/events.ts b/extensions/qa-matrix/src/substrate/events.ts
index 83b5334c6fe..b15a53c90a9 100644
--- a/extensions/qa-matrix/src/substrate/events.ts
+++ b/extensions/qa-matrix/src/substrate/events.ts
@@ -104,6 +104,10 @@ function resolveMatrixQaAttachmentKind(msgtype: string | undefined) {
}
}
+function isLikelyMatrixQaFilenameBody(value: string) {
+ return !value.includes("\n") && /\.[a-z0-9][a-z0-9._-]{0,24}$/i.test(value);
+}
+
function resolveMatrixQaAttachmentSummary(params: {
body?: string;
filename?: string;
@@ -114,10 +118,14 @@ function resolveMatrixQaAttachmentSummary(params: {
return undefined;
}
const body = params.body?.trim() ?? "";
- const filename = params.filename?.trim() ?? "";
+ const explicitFilename = params.filename?.trim() ?? "";
+ const inferredFilename =
+ !explicitFilename && body && isLikelyMatrixQaFilenameBody(body) ? body : "";
+ const filename = explicitFilename || inferredFilename;
+ const caption = body && body !== filename ? body : "";
return {
kind,
- ...(body && body !== filename ? { caption: body } : {}),
+ ...(caption ? { caption } : {}),
...(filename ? { filename } : {}),
};
}
@@ -164,6 +172,11 @@ export function normalizeMatrixQaObservedEvent(
type === "m.reaction" && typeof relatesTo?.event_id === "string"
? relatesTo.event_id
: undefined;
+ const attachment = resolveMatrixQaAttachmentSummary({
+ body: typeof messageContent.body === "string" ? messageContent.body : undefined,
+ filename: normalizedFilename,
+ msgtype: normalizedMsgtype,
+ });
return {
kind: resolveMatrixQaObservedEventKind({ msgtype: normalizedMsgtype, type }),
@@ -208,18 +221,6 @@ export function normalizeMatrixQaObservedEvent(
},
}
: {}),
- ...(resolveMatrixQaAttachmentSummary({
- body: typeof messageContent.body === "string" ? messageContent.body : undefined,
- filename: normalizedFilename,
- msgtype: normalizedMsgtype,
- })
- ? {
- attachment: resolveMatrixQaAttachmentSummary({
- body: typeof messageContent.body === "string" ? messageContent.body : undefined,
- filename: normalizedFilename,
- msgtype: normalizedMsgtype,
- }),
- }
- : {}),
+ ...(attachment ? { attachment } : {}),
};
}