Skip to content

Commit 6fcdcba

Browse files
author
DavidQ
committed
Add session size guards for URL and storage with explicit INVALID state handling - PR 11.225
1 parent 02d4f8b commit 6fcdcba

8 files changed

Lines changed: 396 additions & 42 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# PR_11_225 Report - V2 Session Size Guard (URL + Storage Limits)
2+
3+
## Files Changed
4+
- `tools/workspace-v2/index.js`
5+
- `tools/asset-browser-v2/index.js`
6+
- `tools/palette-manager-v2/index.js`
7+
- `tools/svg-asset-studio-v2/index.js`
8+
- `tools/tilemap-studio-v2/index.js`
9+
- `tools/vector-map-editor-v2/index.js`
10+
- `tests/runtime/V2SessionSize.test.mjs`
11+
- `docs/dev/reports/PR_11_225_report.md`
12+
13+
## Thresholds Used
14+
- URL safe length limit: `2000` characters
15+
- Session payload size limit: `1,048,576` bytes (`1 MB`)
16+
17+
## Guard Behavior
18+
### Workspace V2
19+
- Added size guard constants in `workspace-v2`:
20+
- `this.urlLengthLimit = 2000`
21+
- `this.sessionPayloadBytesLimit = 1024 * 1024`
22+
- Added payload-byte validation before sessionStorage writes:
23+
- `applySessionPayload(...)`
24+
- `createSessionAndLaunch(...)`
25+
- Added URL length guard for share links:
26+
- blocks share-link creation when URL exceeds limit
27+
- blocks decode when encoded session param exceeds limit
28+
- On exceed:
29+
- operation does not proceed
30+
- no truncation
31+
- actionable message starts with:
32+
- `Session size exceeds allowed limit...`
33+
34+
### V2 Tool Readers
35+
- Added read-side size validation in all target `tools/*-v2/index.js` files:
36+
- checks raw session string length before `JSON.parse`
37+
- oversize payload routes to `renderError(...)` with:
38+
- `Session size exceeds allowed limit...`
39+
- keeps INVALID state behavior explicit
40+
41+
## Runtime Size Tests
42+
Implemented `tests/runtime/V2SessionSize.test.mjs` cases:
43+
1. Under limit payload -> `VALID`
44+
2. Over URL limit payload -> `INVALID` (URL guard)
45+
3. Over storage limit payload -> `INVALID` (storage guard)
46+
47+
Output:
48+
- `tmp/v2-session-size-results.json`
49+
50+
## Validation Results
51+
Commands run:
52+
1. `node --check tests/runtime/V2SessionSize.test.mjs`
53+
Result: **PASS**
54+
2. `node tests/runtime/V2SessionSize.test.mjs`
55+
Result: **PASS**
56+
3. `node --check tools/workspace-v2/index.js`
57+
Result: **PASS**
58+
59+
Additional runtime assertions passed:
60+
- all five V2 tools contain size-limit read validation
61+
- no syntax failures in modified V2 tool JS files
62+
63+
## No Fallback Confirmation
64+
- No payload splitting added.
65+
- No compression workaround added.
66+
- No silent truncation added.
67+
- No fallback/default/demo data introduced.
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import assert from "node:assert/strict";
2+
import fs from "node:fs";
3+
import path from "node:path";
4+
import { execFileSync } from "node:child_process";
5+
import { fileURLToPath, pathToFileURL } from "node:url";
6+
7+
const __filename = fileURLToPath(import.meta.url);
8+
const __dirname = path.dirname(__filename);
9+
const repoRoot = path.resolve(__dirname, "..", "..");
10+
const workspaceJsPath = path.join(repoRoot, "tools", "workspace-v2", "index.js");
11+
const toolsRoot = path.join(repoRoot, "tools");
12+
const resultsPath = path.join(repoRoot, "tmp", "v2-session-size-results.json");
13+
14+
const URL_LENGTH_LIMIT = 2000;
15+
const SESSION_PAYLOAD_BYTES_LIMIT = 1024 * 1024;
16+
const TOOL_IDS = [
17+
"asset-browser-v2",
18+
"palette-manager-v2",
19+
"svg-asset-studio-v2",
20+
"tilemap-studio-v2",
21+
"vector-map-editor-v2"
22+
];
23+
24+
function readText(filePath) {
25+
return fs.readFileSync(filePath, "utf8");
26+
}
27+
28+
function checkJsSyntax(jsPath) {
29+
try {
30+
execFileSync(process.execPath, ["--check", jsPath], {
31+
cwd: repoRoot,
32+
stdio: ["ignore", "pipe", "pipe"]
33+
});
34+
return { syntaxValid: true, syntaxError: "" };
35+
} catch (error) {
36+
return {
37+
syntaxValid: false,
38+
syntaxError: (error?.stderr || error?.stdout || error?.message || "").toString().trim()
39+
};
40+
}
41+
}
42+
43+
function sessionPayloadMetrics(sessionPayload) {
44+
const serializedPayload = JSON.stringify(sessionPayload);
45+
return {
46+
serializedPayload,
47+
bytes: new TextEncoder().encode(serializedPayload).length
48+
};
49+
}
50+
51+
function encodeSessionPayload(sessionPayload) {
52+
const json = JSON.stringify(sessionPayload);
53+
const bytes = new TextEncoder().encode(json);
54+
return Buffer.from(bytes).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
55+
}
56+
57+
function validateStorageLimit(sessionPayload) {
58+
const metrics = sessionPayloadMetrics(sessionPayload);
59+
if (metrics.bytes > SESSION_PAYLOAD_BYTES_LIMIT) {
60+
return {
61+
state: "INVALID",
62+
message: `Session size exceeds allowed limit. Payload is ${metrics.bytes} bytes and limit is ${SESSION_PAYLOAD_BYTES_LIMIT} bytes.`,
63+
metrics
64+
};
65+
}
66+
return { state: "VALID", message: "Storage size is within limit.", metrics };
67+
}
68+
69+
function validateUrlLimit(sessionPayload) {
70+
const encoded = encodeSessionPayload(sessionPayload);
71+
const shareUrl = new URL("https://example.test/tools/workspace-v2/index.html");
72+
shareUrl.searchParams.set("session", encoded);
73+
if (shareUrl.toString().length > URL_LENGTH_LIMIT) {
74+
return {
75+
state: "INVALID",
76+
message: `Session size exceeds allowed limit for URL payload. URL length is ${shareUrl.toString().length} and limit is ${URL_LENGTH_LIMIT}.`,
77+
encodedLength: encoded.length,
78+
urlLength: shareUrl.toString().length
79+
};
80+
}
81+
return {
82+
state: "VALID",
83+
message: "URL size is within limit.",
84+
encodedLength: encoded.length,
85+
urlLength: shareUrl.toString().length
86+
};
87+
}
88+
89+
function buildPayload(stringLength) {
90+
return {
91+
toolId: "asset-browser-v2",
92+
payloadJson: {
93+
assetCatalog: {
94+
name: "Session Size Fixture",
95+
entries: [
96+
{
97+
id: "asset-size-001",
98+
label: "Large Entry",
99+
kind: "svg",
100+
path: `assets/${"x".repeat(stringLength)}.svg`
101+
}
102+
]
103+
}
104+
}
105+
};
106+
}
107+
108+
function validateToolReadGuard(toolId) {
109+
const jsPath = path.join(toolsRoot, toolId, "index.js");
110+
const jsExists = fs.existsSync(jsPath);
111+
const jsText = jsExists ? readText(jsPath) : "";
112+
const { syntaxValid, syntaxError } = checkJsSyntax(jsPath);
113+
const failures = [];
114+
const hasLimitConstant = jsText.includes("this.sessionPayloadBytesLimit = 1024 * 1024");
115+
const hasLimitMessage = jsText.includes("Session size exceeds allowed limit.");
116+
const hasSerializedSessionVariable = jsText.includes("const serializedSession = window.sessionStorage.getItem(");
117+
if (!jsExists) failures.push("Missing tool index.js.");
118+
if (!syntaxValid) failures.push("Tool index.js failed syntax check.");
119+
if (!hasLimitConstant) failures.push("Missing session payload size limit constant.");
120+
if (!hasLimitMessage) failures.push("Missing size limit INVALID message.");
121+
if (!hasSerializedSessionVariable) failures.push("Missing serialized session read path.");
122+
return {
123+
tool: toolId,
124+
jsPath: path.relative(repoRoot, jsPath).replace(/\\/g, "/"),
125+
jsExists,
126+
syntaxValid,
127+
syntaxError,
128+
hasLimitConstant,
129+
hasLimitMessage,
130+
hasSerializedSessionVariable,
131+
failures
132+
};
133+
}
134+
135+
export function run() {
136+
const failures = [];
137+
const workspaceJsExists = fs.existsSync(workspaceJsPath);
138+
const workspaceJsText = workspaceJsExists ? readText(workspaceJsPath) : "";
139+
const workspaceSyntax = checkJsSyntax(workspaceJsPath);
140+
141+
const hasWorkspaceUrlLimit = workspaceJsText.includes("this.urlLengthLimit = 2000");
142+
const hasWorkspaceStorageLimit = workspaceJsText.includes("this.sessionPayloadBytesLimit = 1024 * 1024");
143+
const hasStorageGuardMethod = workspaceJsText.includes("validateSessionPayloadSize(sessionPayload)");
144+
const hasUrlGuardMessage = workspaceJsText.includes("Session size exceeds allowed limit for URL payload");
145+
const hasStorageGuardMessage = workspaceJsText.includes("Session size exceeds allowed limit. Payload is");
146+
147+
if (!workspaceJsExists) failures.push("Missing tools/workspace-v2/index.js.");
148+
if (!workspaceSyntax.syntaxValid) failures.push("tools/workspace-v2/index.js failed syntax check.");
149+
if (!hasWorkspaceUrlLimit) failures.push("Missing workspace URL size limit constant.");
150+
if (!hasWorkspaceStorageLimit) failures.push("Missing workspace storage size limit constant.");
151+
if (!hasStorageGuardMethod) failures.push("Missing workspace validateSessionPayloadSize(sessionPayload).");
152+
if (!hasUrlGuardMessage) failures.push("Missing workspace URL size guard message.");
153+
if (!hasStorageGuardMessage) failures.push("Missing workspace storage size guard message.");
154+
155+
const underLimitPayload = buildPayload(200);
156+
const overUrlLimitPayload = buildPayload(2600);
157+
const overStorageLimitPayload = buildPayload(SESSION_PAYLOAD_BYTES_LIMIT + 5000);
158+
159+
const underStorageResult = validateStorageLimit(underLimitPayload);
160+
const underUrlResult = validateUrlLimit(underLimitPayload);
161+
const overUrlStorageResult = validateStorageLimit(overUrlLimitPayload);
162+
const overUrlResult = validateUrlLimit(overUrlLimitPayload);
163+
const overStorageResult = validateStorageLimit(overStorageLimitPayload);
164+
const overStorageUrlResult = validateUrlLimit(overStorageLimitPayload);
165+
166+
if (underStorageResult.state !== "VALID" || underUrlResult.state !== "VALID") {
167+
failures.push("Under-limit payload should be VALID for storage and URL.");
168+
}
169+
if (overUrlStorageResult.state !== "VALID") {
170+
failures.push("URL-over-limit payload should remain storage-valid.");
171+
}
172+
if (overUrlResult.state !== "INVALID") {
173+
failures.push("URL-over-limit payload should be INVALID for URL guard.");
174+
}
175+
if (overStorageResult.state !== "INVALID") {
176+
failures.push("Storage-over-limit payload should be INVALID for storage guard.");
177+
}
178+
if (!overStorageResult.message.includes("Session size exceeds allowed limit")) {
179+
failures.push("Storage-over-limit payload did not produce actionable limit message.");
180+
}
181+
if (overStorageUrlResult.state !== "INVALID") {
182+
failures.push("Storage-over-limit payload should also exceed URL limit.");
183+
}
184+
185+
const toolRows = TOOL_IDS.map(validateToolReadGuard);
186+
toolRows.forEach((row) => {
187+
row.failures.forEach((entry) => failures.push(`${row.tool}: ${entry}`));
188+
});
189+
190+
fs.mkdirSync(path.dirname(resultsPath), { recursive: true });
191+
fs.writeFileSync(resultsPath, `${JSON.stringify({
192+
generatedAt: new Date().toISOString(),
193+
thresholds: {
194+
urlLengthLimit: URL_LENGTH_LIMIT,
195+
sessionPayloadBytesLimit: SESSION_PAYLOAD_BYTES_LIMIT
196+
},
197+
failures,
198+
workspaceChecks: {
199+
workspaceJsExists,
200+
syntaxValid: workspaceSyntax.syntaxValid,
201+
syntaxError: workspaceSyntax.syntaxError,
202+
hasWorkspaceUrlLimit,
203+
hasWorkspaceStorageLimit,
204+
hasStorageGuardMethod,
205+
hasUrlGuardMessage,
206+
hasStorageGuardMessage
207+
},
208+
cases: {
209+
underLimit: {
210+
storage: underStorageResult,
211+
url: underUrlResult
212+
},
213+
overUrlLimit: {
214+
storage: overUrlStorageResult,
215+
url: overUrlResult
216+
},
217+
overStorageLimit: {
218+
storage: overStorageResult,
219+
url: overStorageUrlResult
220+
}
221+
},
222+
toolReadValidation: toolRows
223+
}, null, 2)}\n`, "utf8");
224+
225+
console.log(`v2 session size results: ${resultsPath}`);
226+
assert.equal(failures.length, 0, `V2 session size failures: ${failures.join(" | ")}`);
227+
return { failures, toolRows };
228+
}
229+
230+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
231+
try {
232+
const summary = run();
233+
console.log(JSON.stringify(summary, null, 2));
234+
} catch (error) {
235+
console.error(error);
236+
process.exitCode = 1;
237+
}
238+
}

tools/asset-browser-v2/index.js

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
class AssetBrowserV2 {
22
constructor() {
33
console.log("[AssetBrowserV2]");
4+
this.sessionPayloadBytesLimit = 1024 * 1024;
45
document.title = "Asset Browser V2";
56
document.body.dataset.toolId = "asset-browser-v2";
67
this.urlState = this.readUrlState();
@@ -97,20 +98,21 @@ class AssetBrowserV2 {
9798
this.renderMissing("No hostContextId was provided. Re-open Asset Browser V2 from a valid Tool V2 session link.");
9899
return;
99100
}
101+
const serializedSession = window.sessionStorage.getItem(
102+
this.urlState.hostContextId
103+
);
100104
if (
101-
!window.sessionStorage.getItem(
102-
this.urlState.hostContextId
103-
)
105+
!serializedSession
104106
) {
105107
this.renderMissing("No session data was found for the provided hostContextId. Re-open Asset Browser V2 from the tools index or a host flow that creates the session context first.");
106108
return;
107109
}
110+
if (serializedSession.length > this.sessionPayloadBytesLimit) {
111+
this.renderError(`Session size exceeds allowed limit. Payload is ${serializedSession.length} bytes and limit is ${this.sessionPayloadBytesLimit} bytes.`);
112+
return;
113+
}
108114
this.loadContract(
109-
JSON.parse(
110-
window.sessionStorage.getItem(
111-
this.urlState.hostContextId
112-
)
113-
)
115+
JSON.parse(serializedSession)
114116
);
115117
} catch (error) {
116118
const runtimeMessage = `Unable to read Asset Browser V2 session context: ${error instanceof Error ? error.message : "unknown error"}`;

tools/palette-manager-v2/index.js

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
class PaletteManagerV2 {
22
constructor() {
33
console.log("[PaletteManagerV2]");
4+
this.sessionPayloadBytesLimit = 1024 * 1024;
45
document.title = "Palette Manager V2";
56
document.body.dataset.toolId = "palette-manager-v2";
67
this.urlState = this.readUrlState();
@@ -97,20 +98,21 @@ class PaletteManagerV2 {
9798
this.renderMissing("No hostContextId was provided. Re-open Palette Manager V2 from a valid Tool V2 session link.");
9899
return;
99100
}
101+
const serializedSession = window.sessionStorage.getItem(
102+
this.urlState.hostContextId
103+
);
100104
if (
101-
!window.sessionStorage.getItem(
102-
this.urlState.hostContextId
103-
)
105+
!serializedSession
104106
) {
105107
this.renderMissing("No session data was found for the provided hostContextId. Re-open Palette Manager V2 from the tools index or a host flow that creates the session context first.");
106108
return;
107109
}
110+
if (serializedSession.length > this.sessionPayloadBytesLimit) {
111+
this.renderError(`Session size exceeds allowed limit. Payload is ${serializedSession.length} bytes and limit is ${this.sessionPayloadBytesLimit} bytes.`);
112+
return;
113+
}
108114
this.loadContract(
109-
JSON.parse(
110-
window.sessionStorage.getItem(
111-
this.urlState.hostContextId
112-
)
113-
)
115+
JSON.parse(serializedSession)
114116
);
115117
} catch (error) {
116118
const runtimeMessage = `Unable to read Palette Manager V2 session context: ${error instanceof Error ? error.message : "unknown error"}`;

0 commit comments

Comments
 (0)