Skip to content

Feat/excel m4 structural - #326

Merged
SamTV12345 merged 6 commits into
mainfrom
feat/excel-m4-structural
Jul 2, 2026
Merged

Feat/excel m4 structural#326
SamTV12345 merged 6 commits into
mainfrom
feat/excel-m4-structural

Conversation

@SamTV12345

Copy link
Copy Markdown
Member

No description provided.

@SamTV12345
SamTV12345 enabled auto-merge (squash) July 2, 2026 18:50
@qodo-code-review

qodo-code-review Bot commented Jul 2, 2026

Copy link
Copy Markdown

PR Summary by Qodo

Spreadsheet M4: multi-sheet ops, resize/freeze, tabs, sort & filter

✨ Enhancement 🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Add multi-sheet structural ops plus dimension/freeze metadata with snapshot round-trips.
• Add tabs, resize, freeze, sort, and client-local filter to the sheet UI.
• Fix websocket relays/rate-limits and harden presence + fill-handle editing behavior.
Diagram

graph TD
  ui_editor["Sheet editor"] --> ui_view["Grid view"] --> ui_collab["Collab client"] --> ws["WebSocket"] --> handler["Sheet handler"] --> engine["lib/sheet ops"] --> snap["Snapshot JSON"]
  ui_editor --> ui_controls["Tabs + toolbar"] --> ui_collab
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Add a first-class server-side sort op
  • ➕ Fewer ops on the wire (avoid per-cell setCell bursts)
  • ➕ Single authoritative formula-adjust/ordering implementation
  • ➕ Potentially easier to make sort fully collaborative with intent preserved
  • ➖ Requires new op semantics + Transform rules across concurrent edits
  • ➖ Adds server complexity and more surface area for convergence bugs
  • ➖ Harder to keep TS/Go mirrors in lockstep
2. Make filter collaborative via ops
  • ➕ All collaborators see the same filtered rows/view state
  • ➕ Filter state can survive reloads and reconnects as part of the log
  • ➖ Filter is fundamentally view/UI state; encoding it into the op log can be surprising
  • ➖ Requires conflict rules for concurrent filters and sheet switches
  • ➖ More protocol/API design work for v1
3. Virtualize grid rendering instead of growing to 200×52 DOM cells
  • ➕ Better performance headroom for larger sheets
  • ➕ Avoids 10k+ contenteditables impacting low-end clients
  • ➖ Much more complex selection/scroll/editing interactions
  • ➖ Harder to keep resize/freeze/presence decorations correct
  • ➖ Not required for the current bounded grid target

Recommendation: The PR’s approach is sound for M4: keep the server protocol minimal by expressing sort as a batch of setCell ops, and keep filter client-local until there’s a clear collaboration model. The one place to revisit soon is performance: if grid size increases beyond the current hard bounds, prioritize virtualization to avoid DOM scalability limits.

Files changed (21) +1227 / -48

Enhancement (13) +704 / -39
apply.goApply sheet-list + dimension/freeze ops; unknown-sheet becomes no-op +56/-2

Apply sheet-list + dimension/freeze ops; unknown-sheet becomes no-op

• Adds Apply handling for add/rename/move/delete sheet ops, with convergence rules (duplicate add no-op, never delete last sheet). Implements setDimension and setFreeze, shifting dimension overrides under structural row/col inserts/deletes, and changes unknown-sheet behavior from error to silent no-op.

lib/sheet/apply.go

op.goExtend op model with sheet-list, dimension, and freeze fields +52/-1

Extend op model with sheet-list, dimension, and freeze fields

• Introduces new OpType values for sheet management and grid metadata. Extends Op with name/toIndex/axis/size/frozenRows/frozenCols and adds validation rules for each new op type.

lib/sheet/op.go

sheet.goAdd per-sheet dimension overrides and freeze metadata +31/-4

Add per-sheet dimension overrides and freeze metadata

• Extends Sheet with sparse column width/row height maps and frozen row/col flags. Updates constructors/cloning and adds shiftDims to remap sparse dimension overrides under structural inserts/deletes.

lib/sheet/sheet.go

snapshot.goPersist dimensions and freeze state in snapshots +20/-2

Persist dimensions and freeze state in snapshots

• Extends SheetSnapshot serialization to include colWidths/rowHeights and frozenRows/frozenCols. Updates snapshot creation and restore to round-trip the new metadata.

lib/sheet/snapshot.go

transform.goTransform setDimension indices under row/col shifts +6/-0

Transform setDimension indices under row/col shifts

• Updates Transform helpers to shift setDimension.Index when structural ops insert/delete on the matching axis, keeping dimension overrides correctly rebased.

lib/sheet/transform.go

op.tsMirror new structural op types and fields in TS +17/-2

Mirror new structural op types and fields in TS

• Extends the TS OpType union and Op interface to include sheet-list ops, setDimension, and setFreeze fields, aligning client/server op schemas.

ui/src/js/sheet/op.ts

sheetEditor.tsWire tabs, sort/filter, resize, freeze, and larger grid into editor +78/-2

Wire tabs, sort/filter, resize, freeze, and larger grid into editor

• Grows the grid bounds to 200×52 and integrates new UI components (tabs bar and toolbar actions). Adds client-local filter state, emits setDimension and setFreeze ops, and applies collaborative sort as a batch of setCell ops.

ui/src/js/sheet/sheetEditor.ts

sheetSortFilter.tsIntroduce pure sort/filter helper module +90/-0

Introduce pure sort/filter helper module

• Implements collaborative sort as deterministic setCell op generation (with formula adjustment) and client-local filtering via distinct value extraction and hidden-row computation.

ui/src/js/sheet/sheetSortFilter.ts

sheetTabs.tsAdd sheet tabs bar (switch/rename/delete/reorder/add) +77/-0

Add sheet tabs bar (switch/rename/delete/reorder/add)

• Creates a lightweight DOM tabs component with click switch, prompt-based rename, confirm-based delete (protect last sheet), HTML5 drag reordering, and add-sheet button. Exposes refresh() for rerendering after workbook changes.

ui/src/js/sheet/sheetTabs.ts

sheetToolbar.tsAdd sort, freeze toggles, and filter dropdown to toolbar +61/-0

Add sort, freeze toggles, and filter dropdown to toolbar

• Extends the toolbar to optionally show A→Z/Z→A sort buttons, freeze row/col toggles with active-state tracking, and a lazily populated filter dropdown that drives client-local row hiding.

ui/src/js/sheet/sheetToolbar.ts

sheetView.tsSupport resize/freeze/filter and move fill handle to an overlay +126/-20

Support resize/freeze/filter and move fill handle to an overlay

• Adds header drag resizers with live preview and a single commit on mouseup, applies sparse row/col dimension overrides, supports sticky frozen first row/col, and collapses hidden rows for filtering. Fixes fill-handle editing by rendering it as an overlay outside contenteditable cells and hiding it during active editing of the focus cell.

ui/src/js/sheet/sheetView.ts

transform.tsTransform setDimension indices under row/col shifts (TS) +6/-0

Transform setDimension indices under row/col shifts (TS)

• Mirrors the Go Transform change so setDimension ops are rebased when inserts/deletes happen on the corresponding axis.

ui/src/js/sheet/transform.ts

workbookState.tsExtend client workbook state with sheets, dimensions, and freeze metadata +84/-6

Extend client workbook state with sheets, dimensions, and freeze metadata

• Adds per-sheet sparse dimension maps and freeze flags, snapshot load support for new fields, and Apply behavior for sheet-list/dimension/freeze ops. Updates structural insert/delete logic to shift/drops dimension overrides to match server semantics and changes missing-sheet behavior to no-op for convergence.

ui/src/js/sheet/workbookState.ts

Bug fix (2) +13 / -7
client.goRemove raw websocket rebroadcast; rate-limit only commit messages +12/-7

Remove raw websocket rebroadcast; rate-limit only commit messages

• Stops readPump from broadcasting raw incoming frames to all clients (preventing token/protocol leakage and presence echo artifacts). Applies CommitRateLimiting only to commit-like messages (USER_CHANGES/SHEET_OP) so ephemeral presence traffic does not exhaust the commit budget.

lib/ws/client.go

sheetPresence.tsIgnore presence frames without userId +1/-0

Ignore presence frames without userId

• Adds a guard to drop frames missing userId to avoid rendering non-authoritative/echoed frames as phantom collaborators.

ui/src/js/sheet/sheetPresence.ts

Tests (5) +454 / -2
apply_test.goUpdate unknown-sheet Apply test to assert no-op semantics +4/-2

Update unknown-sheet Apply test to assert no-op semantics

• Adjusts TestApplyUnknownSheet to expect silent no-op instead of an error, matching the new convergence rule for ops arriving after a deleteSheet.

lib/sheet/apply_test.go

structural_test.goAdd comprehensive structural op tests (Go) +195/-0

Add comprehensive structural op tests (Go)

• Adds tests for sheet-list ops (including convergence/no-op rules), setDimension/setFreeze storage and shifting behavior, snapshot round-trip, and Transform behavior for setDimension under inserts.

lib/sheet/structural_test.go

sheet_structural.spec.tsAdd Playwright e2e coverage for M4 structural features +102/-0

Add Playwright e2e coverage for M4 structural features

• Adds end-to-end tests for tabs (add/switch/rename and per-sheet isolation), column resize persistence, frozen row stickiness, sort A→Z, and client-local filtering behavior.

playwright/specs/sheet_structural.spec.ts

sheetSortFilter.test.tsAdd unit tests for sort/filter helpers +69/-0

Add unit tests for sort/filter helpers

• Tests numeric vs lexical comparisons, sortRangeOps row movement and formula ref adjustment, distinct value extraction, and row hiding rules for filtering.

ui/src/js/sheet/sheetSortFilter.test.ts

structural.test.tsAdd TS structural tests mirroring Go behavior +84/-0

Add TS structural tests mirroring Go behavior

• Adds vitest coverage for sheet-list ops, last-sheet delete protection, no-op behavior on missing sheets, dimension/freeze storage and shifting, snapshot loading, and transform behavior for setDimension.

ui/src/js/sheet/structural.test.ts

Documentation (1) +56 / -0
2026-07-02-excel-m4-structural.mdAdd M4 structural plan and scope notes +56/-0

Add M4 structural plan and scope notes

• Documents the M4 spreadsheet milestone: new ops (sheet list, dimensions, freeze), UI work (tabs, resize, freeze, sort/filter), and test coverage. Calls out deliberate ceilings like client-local filtering and lack of virtualization.

docs/superpowers/plans/2026-07-02-excel-m4-structural.md

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jul 2, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (10) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Invalid sheet ops deadlock 🐞 Bug ☼ Reliability
Description
The UI can emit invalid setDimension sizes (>4096) and renameSheet names (>128), which the
server rejects in Op.Validate() but handleSheetOp returns without sending ACCEPT, leaving
SheetCollabClient stuck in committing=true until reload.
Code

ui/src/js/sheet/sheetView.ts[R174-195]

+  // attachResizer adds a drag strip to a header cell edge. Dragging previews
+  // the size live via inline style and commits once on mouseup via onResize.
+  private attachResizer(th: HTMLTableCellElement, axis: 'col' | 'row', index: number): void {
+    if (this.opts.readOnly) return;
+    const grip = document.createElement('span');
+    grip.className = axis === 'col' ? 'sheet-resizer-col' : 'sheet-resizer-row';
+    grip.addEventListener('mousedown', (e: MouseEvent) => {
+      e.stopPropagation();
+      e.preventDefault();
+      const startPos = axis === 'col' ? e.clientX : e.clientY;
+      const startSize = axis === 'col' ? th.offsetWidth : th.offsetHeight;
+      const minSize = axis === 'col' ? 40 : 18;
+      let size = startSize;
+      const onMove = (me: MouseEvent) => {
+        size = Math.max(minSize, startSize + ((axis === 'col' ? me.clientX : me.clientY) - startPos));
+        this.applyDim(axis, index, size);
+      };
+      const onUp = () => {
+        document.removeEventListener('mousemove', onMove);
+        document.removeEventListener('mouseup', onUp);
+        this.opts.onResize?.(axis, index, size);
+      };
Evidence
setDimension sizing is unbounded in the drag handler, and tab rename accepts arbitrary prompt
text; the server validates size/name limits and rejects invalid ops, but the WS handler returns
early on error without ACK, while the client only clears committing on ACCEPT.

ui/src/js/sheet/sheetView.ts[174-195]
ui/src/js/sheet/sheetTabs.ts[45-48]
lib/sheet/op.go[116-145]
lib/ws/SheetHandler.go[82-106]
ui/src/js/sheet/sheetCollabClient.ts[51-69]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The client can generate structurally invalid sheet ops (notably `setDimension` sizes above the server’s max and `renameSheet` names exceeding the server’s max). When the server rejects these ops, it does not send an ACCEPT/negative-ACK response, so the client’s collab state machine can remain stuck in a committing state.
### Issue Context
- `setDimension` is generated from drag-resize and has no upper bound.
- `renameSheet` accepts arbitrary prompt input without enforcing the server’s length constraint.
- Server-side validation rejects these inputs.
- WS handler drops the op on error and does not send ACCEPT.
- Client clears `committing` only on ACCEPT.
### Fix Focus Areas
- ui/src/js/sheet/sheetView.ts[174-195]
- ui/src/js/sheet/sheetTabs.ts[45-48]
- lib/sheet/op.go[116-145]
- lib/ws/SheetHandler.go[82-106]
- ui/src/js/sheet/sheetCollabClient.ts[51-69]
### Suggested fix
1. Clamp resize sizes client-side before sending `setDimension`:
- `size = Math.min(size, 4096)` (and possibly also clamp to >=1, though UI already has minSize).
2. Enforce `renameSheet` constraints client-side before sending:
- Reject/trim names >128 characters (and optionally show an inline error).
3. (Optional hardening) Add a server-side negative ACK path for rejected ops so the client can clear `committing` and surface an error instead of silently stalling.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Rate limit payload substring match 🐞 Bug ☼ Reliability
Description
Client.readPump() applies commit rate limiting using strings.Contains() on the entire JSON
message, so non-commit frames (notably SHEET_PRESENCE) can be incorrectly rate-limited if
user-controlled fields contain "USER_CHANGES" or "SHEET_OP". This can drain the commit budget via
presence-keystroke traffic and cause actual commits to be skipped (continue), resulting in lost
edits.
Code

lib/ws/client.go[R135-145]

+		// CommitRateLimiting only covers commits (USER_CHANGES / SHEET_OP), as
+		// in etherpad-lite. Ephemeral traffic like SHEET_PRESENCE arrives per
+		// keystroke and must not burn the commit budget — a drained budget
+		// silently drops the commit itself and edits are lost.
+		if strings.Contains(decodedMessage, "USER_CHANGES") || strings.Contains(decodedMessage, "SHEET_OP") {
+			retrievedSettings.CommitRateLimiting.LoadTest = retrievedSettings.LoadTest
+			if err := ratelimiter.CheckRateLimit(ratelimiter.IPAddress(c.ClientIP), retrievedSettings.CommitRateLimiting); err != nil {
+				logger.Warn("Rate limit exceeded:", err.Error())
+				continue
+			}
+		}
Relevance

⭐⭐ Medium

WS codebase commonly uses strings.Contains routing; no precedent found requiring JSON type parsing
for rate limiting.

PR-#276
PR-#28
PR-#294

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The rate limiter gates on substring matches in the full JSON message, while presence frames include
user-controlled raw content; therefore a presence message can accidentally (or intentionally)
match the commit substrings and be rate-limited/dropped before handling.

lib/ws/client.go[132-145]
ui/src/js/sheet/sheetEditor.ts[59-66]
lib/models/ws/sheetMessages.go[61-80]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`lib/ws/client.go` currently decides whether to apply `CommitRateLimiting` by doing substring checks against the *entire* websocket JSON payload. Because `SHEET_PRESENCE` includes user-controlled `raw` text, any presence message that happens to contain the substrings `"USER_CHANGES"` or `"SHEET_OP"` will be treated as a commit and can be dropped, which can also exhaust the commit budget and lead to dropped commits.
### Issue Context
- Presence frames contain user input (`raw`) and are sent frequently (per keystroke).
- The rate limit check happens before dispatch/unmarshal and uses `continue` on failure.
### Fix Focus Areas
- lib/ws/client.go[135-145]
### Suggested fix
1. Unmarshal a minimal envelope struct (or otherwise reliably extract the message's logical type) and apply commit rate limiting only when the parsed type is exactly `USER_CHANGES` or `SHEET_OP`.
2. Avoid scanning arbitrary payload strings for routing/limiting decisions; if you keep substring-based routing for legacy reasons, at least make the *rate limiting* decision based on parsed `data.data.type`.
3. Add a regression test: a `SHEET_PRESENCE` frame whose `raw` includes `"SHEET_OP"` must not consume the commit budget / must not be dropped by commit rate limiting.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Rate limit payload substring match 🐞 Bug ☼ Reliability
Description
Client.readPump() applies commit rate limiting using strings.Contains() on the entire JSON
message, so non-commit frames (notably SHEET_PRESENCE) can be incorrectly rate-limited if
user-controlled fields contain "USER_CHANGES" or "SHEET_OP". This can drain the commit budget via
presence-keystroke traffic and cause actual commits to be skipped (continue), resulting in lost
edits.
Code

lib/ws/client.go[R135-145]

+		// CommitRateLimiting only covers commits (USER_CHANGES / SHEET_OP), as
+		// in etherpad-lite. Ephemeral traffic like SHEET_PRESENCE arrives per
+		// keystroke and must not burn the commit budget — a drained budget
+		// silently drops the commit itself and edits are lost.
+		if strings.Contains(decodedMessage, "USER_CHANGES") || strings.Contains(decodedMessage, "SHEET_OP") {
+			retrievedSettings.CommitRateLimiting.LoadTest = retrievedSettings.LoadTest
+			if err := ratelimiter.CheckRateLimit(ratelimiter.IPAddress(c.ClientIP), retrievedSettings.CommitRateLimiting); err != nil {
+				logger.Warn("Rate limit exceeded:", err.Error())
+				continue
+			}
+		}
Relevance

⭐⭐ Medium

WS codebase commonly uses strings.Contains routing; no precedent found requiring JSON type parsing
for rate limiting.

PR-#276
PR-#28
PR-#294

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The rate limiter gates on substring matches in the full JSON message, while presence frames include
user-controlled raw content; therefore a presence message can accidentally (or intentionally)
match the commit substrings and be rate-limited/dropped before handling.

lib/ws/client.go[132-145]
ui/src/js/sheet/sheetEditor.ts[59-66]
lib/models/ws/sheetMessages.go[61-80]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`lib/ws/client.go` currently decides whether to apply `CommitRateLimiting` by doing substring checks against the *entire* websocket JSON payload. Because `SHEET_PRESENCE` includes user-controlled `raw` text, any presence message that happens to contain the substrings `"USER_CHANGES"` or `"SHEET_OP"` will be treated as a commit and can be dropped, which can also exhaust the commit budget and lead to dropped commits.
### Issue Context
- Presence frames contain user input (`raw`) and are sent frequently (per keystroke).
- The rate limit check happens before dispatch/unmarshal and uses `continue` on failure.
### Fix Focus Areas
- lib/ws/client.go[135-145]
### Suggested fix
1. Unmarshal a minimal envelope struct (or otherwise reliably extract the message's logical type) and apply commit rate limiting only when the parsed type is exactly `USER_CHANGES` or `SHEET_OP`.
2. Avoid scanning arbitrary payload strings for routing/limiting decisions; if you keep substring-based routing for legacy reasons, at least make the *rate limiting* decision based on parsed `data.data.type`.
3. Add a regression test: a `SHEET_PRESENCE` frame whose `raw` includes `"SHEET_OP"` must not consume the commit budget / must not be dropped by commit rate limiting.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (2)
4. Rate limit payload substring match 🐞 Bug ☼ Reliability
Description
Client.readPump() applies commit rate limiting using strings.Contains() on the entire JSON
message, so non-commit frames (notably SHEET_PRESENCE) can be incorrectly rate-limited if
user-controlled fields contain "USER_CHANGES" or "SHEET_OP". This can drain the commit budget via
presence-keystroke traffic and cause actual commits to be skipped (continue), resulting in lost
edits.
Code

lib/ws/client.go[R135-145]

+		// CommitRateLimiting only covers commits (USER_CHANGES / SHEET_OP), as
+		// in etherpad-lite. Ephemeral traffic like SHEET_PRESENCE arrives per
+		// keystroke and must not burn the commit budget — a drained budget
+		// silently drops the commit itself and edits are lost.
+		if strings.Contains(decodedMessage, "USER_CHANGES") || strings.Contains(decodedMessage, "SHEET_OP") {
+			retrievedSettings.CommitRateLimiting.LoadTest = retrievedSettings.LoadTest
+			if err := ratelimiter.CheckRateLimit(ratelimiter.IPAddress(c.ClientIP), retrievedSettings.CommitRateLimiting); err != nil {
+				logger.Warn("Rate limit exceeded:", err.Error())
+				continue
+			}
+		}
Relevance

⭐⭐ Medium

WS codebase commonly uses strings.Contains routing; no precedent found requiring JSON type parsing
for rate limiting.

PR-#276
PR-#28
PR-#294

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The rate limiter gates on substring matches in the full JSON message, while presence frames include
user-controlled raw content; therefore a presence message can accidentally (or intentionally)
match the commit substrings and be rate-limited/dropped before handling.

lib/ws/client.go[132-145]
ui/src/js/sheet/sheetEditor.ts[59-66]
lib/models/ws/sheetMessages.go[61-80]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`lib/ws/client.go` currently decides whether to apply `CommitRateLimiting` by doing substring checks against the *entire* websocket JSON payload. Because `SHEET_PRESENCE` includes user-controlled `raw` text, any presence message that happens to contain the substrings `"USER_CHANGES"` or `"SHEET_OP"` will be treated as a commit and can be dropped, which can also exhaust the commit budget and lead to dropped commits.
### Issue Context
- Presence frames contain user input (`raw`) and are sent frequently (per keystroke).
- The rate limit check happens before dispatch/unmarshal and uses `continue` on failure.
### Fix Focus Areas
- lib/ws/client.go[135-145]
### Suggested fix
1. Unmarshal a minimal envelope struct (or otherwise reliably extract the message's logical type) and apply commit rate limiting only when the parsed type is exactly `USER_CHANGES` or `SHEET_OP`.
2. Avoid scanning arbitrary payload strings for routing/limiting decisions; if you keep substring-based routing for legacy reasons, at least make the *rate limiting* decision based on parsed `data.data.type`.
3. Add a regression test: a `SHEET_PRESENCE` frame whose `raw` includes `"SHEET_OP"` must not consume the commit budget / must not be dropped by commit rate limiting.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Rate limit payload substring match 🐞 Bug ☼ Reliability
Description
Client.readPump() applies commit rate limiting using strings.Contains() on the entire JSON
message, so non-commit frames (notably SHEET_PRESENCE) can be incorrectly rate-limited if
user-controlled fields contain "USER_CHANGES" or "SHEET_OP". This can drain the commit budget via
presence-keystroke traffic and cause actual commits to be skipped (continue), resulting in lost
edits.
Code

lib/ws/client.go[R135-145]

+		// CommitRateLimiting only covers commits (USER_CHANGES / SHEET_OP), as
+		// in etherpad-lite. Ephemeral traffic like SHEET_PRESENCE arrives per
+		// keystroke and must not burn the commit budget — a drained budget
+		// silently drops the commit itself and edits are lost.
+		if strings.Contains(decodedMessage, "USER_CHANGES") || strings.Contains(decodedMessage, "SHEET_OP") {
+			retrievedSettings.CommitRateLimiting.LoadTest = retrievedSettings.LoadTest
+			if err := ratelimiter.CheckRateLimit(ratelimiter.IPAddress(c.ClientIP), retrievedSettings.CommitRateLimiting); err != nil {
+				logger.Warn("Rate limit exceeded:", err.Error())
+				continue
+			}
+		}
Relevance

⭐⭐ Medium

WS codebase commonly uses strings.Contains routing; no precedent found requiring JSON type parsing
for rate limiting.

PR-#276
PR-#28
PR-#294

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The rate limiter gates on substring matches in the full JSON message, while presence frames include
user-controlled raw content; therefore a presence message can accidentally (or intentionally)
match the commit substrings and be rate-limited/dropped before handling.

lib/ws/client.go[132-145]
ui/src/js/sheet/sheetEditor.ts[59-66]
lib/models/ws/sheetMessages.go[61-80]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`lib/ws/client.go` currently decides whether to apply `CommitRateLimiting` by doing substring checks against the *entire* websocket JSON payload. Because `SHEET_PRESENCE` includes user-controlled `raw` text, any presence message that happens to contain the substrings `"USER_CHANGES"` or `"SHEET_OP"` will be treated as a commit and can be dropped, which can also exhaust the commit budget and lead to dropped commits.
### Issue Context
- Presence frames contain user input (`raw`) and are sent frequently (per keystroke).
- The rate limit check happens before dispatch/unmarshal and uses `continue` on failure.
### Fix Focus Areas
- lib/ws/client.go[135-145]
### Suggested fix
1. Unmarshal a minimal envelope struct (or otherwise reliably extract the message's logical type) and apply commit rate limiting only when the parsed type is exactly `USER_CHANGES` or `SHEET_OP`.
2. Avoid scanning arbitrary payload strings for routing/limiting decisions; if you keep substring-based routing for legacy reasons, at least make the *rate limiting* decision based on parsed `data.data.type`.
3. Add a regression test: a `SHEET_PRESENCE` frame whose `raw` includes `"SHEET_OP"` must not consume the commit budget / must not be dropped by commit rate limiting.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

6. Unbounded sheet ID accepted 🐞 Bug ⛨ Security
Description
Op.Validate() only requires Op.Sheet to be non-empty, but addSheet persists op.Sheet
verbatim as a new sheet ID that is included in snapshots. A malicious client can create sheets with
extremely large or malformed IDs, inflating persisted snapshots and memory usage (resource abuse/DoS
vector).
Code

lib/sheet/apply.go[R16-23]

+	// Sheet-list ops manage w.Sheets itself and never need an existing sheet.
+	switch op.Type {
+	case OpAddSheet:
+		if w.SheetByID(op.Sheet) != nil {
+			return nil // concurrent duplicate add: first wins
+		}
+		w.Sheets = slices.Insert(w.Sheets, min(op.Index, len(w.Sheets)), NewSheet(op.Sheet, op.Name))
+		return nil
Relevance

⭐⭐⭐ High

Team often hardens validation for untrusted/persisted inputs (e.g., op props allowlist PR #320;
persisted params checks PR #300).

PR-#320
PR-#300

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
addSheet uses the caller-provided Op.Sheet as the new sheet ID, but validation only checks
non-empty; snapshots are persisted after ops, so abusive IDs are stored and propagated.

lib/sheet/op.go[79-84]
lib/sheet/apply.go[16-23]
lib/sheetdoc/manager.go[100-112]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
With `addSheet`, the system now accepts a *user-controlled* sheet ID (`Op.Sheet`) and stores it directly into the workbook. `Op.Validate()` currently only checks that `Sheet` is non-empty, so attackers can send arbitrarily long IDs (or odd characters) that bloat snapshots and persisted state.
### Issue Context
- `Workbook.Apply()` inserts `NewSheet(op.Sheet, ...)` for `OpAddSheet`.
- Snapshots are persisted frequently (after each submitted op), so oversized IDs directly impact storage and bandwidth.
### Fix Focus Areas
- lib/sheet/op.go[79-84]
- lib/sheet/apply.go[16-23]
### Suggested fix
1. Add strict validation for `Op.Sheet` in `Op.Validate()` (applies to all ops, but is especially important for `addSheet`):
- Maximum length (e.g., 64 or 128 chars).
- Allowed character set (e.g., `^[A-Za-z0-9_-]+$`).
2. Consider also bounding the maximum number of sheets per workbook (separate decision, but relevant for resource abuse).
3. Add unit tests in `lib/sheet/structural_test.go` to assert invalid IDs are rejected.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Unbounded sheet ID accepted 🐞 Bug ⛨ Security
Description
Op.Validate() only requires Op.Sheet to be non-empty, but addSheet persists op.Sheet
verbatim as a new sheet ID that is included in snapshots. A malicious client can create sheets with
extremely large or malformed IDs, inflating persisted snapshots and memory usage (resource abuse/DoS
vector).
Code

lib/sheet/apply.go[R16-23]

+	// Sheet-list ops manage w.Sheets itself and never need an existing sheet.
+	switch op.Type {
+	case OpAddSheet:
+		if w.SheetByID(op.Sheet) != nil {
+			return nil // concurrent duplicate add: first wins
+		}
+		w.Sheets = slices.Insert(w.Sheets, min(op.Index, len(w.Sheets)), NewSheet(op.Sheet, op.Name))
+		return nil
Relevance

⭐⭐⭐ High

Team often hardens validation for untrusted/persisted inputs (e.g., op props allowlist PR #320;
persisted params checks PR #300).

PR-#320
PR-#300

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
addSheet uses the caller-provided Op.Sheet as the new sheet ID, but validation only checks
non-empty; snapshots are persisted after ops, so abusive IDs are stored and propagated.

lib/sheet/op.go[79-84]
lib/sheet/apply.go[16-23]
lib/sheetdoc/manager.go[100-112]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
With `addSheet`, the system now accepts a *user-controlled* sheet ID (`Op.Sheet`) and stores it directly into the workbook. `Op.Validate()` currently only checks that `Sheet` is non-empty, so attackers can send arbitrarily long IDs (or odd characters) that bloat snapshots and persisted state.
### Issue Context
- `Workbook.Apply()` inserts `NewSheet(op.Sheet, ...)` for `OpAddSheet`.
- Snapshots are persisted frequently (after each submitted op), so oversized IDs directly impact storage and bandwidth.
### Fix Focus Areas
- lib/sheet/op.go[79-84]
- lib/sheet/apply.go[16-23]
### Suggested fix
1. Add strict validation for `Op.Sheet` in `Op.Validate()` (applies to all ops, but is especially important for `addSheet`):
- Maximum length (e.g., 64 or 128 chars).
- Allowed character set (e.g., `^[A-Za-z0-9_-]+$`).
2. Consider also bounding the maximum number of sheets per workbook (separate decision, but relevant for resource abuse).
3. Add unit tests in `lib/sheet/structural_test.go` to assert invalid IDs are rejected.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Unbounded sheet ID accepted 🐞 Bug ⛨ Security
Description
Op.Validate() only requires Op.Sheet to be non-empty, but addSheet persists op.Sheet
verbatim as a new sheet ID that is included in snapshots. A malicious client can create sheets with
extremely large or malformed IDs, inflating persisted snapshots and memory usage (resource abuse/DoS
vector).
Code

lib/sheet/apply.go[R16-23]

+	// Sheet-list ops manage w.Sheets itself and never need an existing sheet.
+	switch op.Type {
+	case OpAddSheet:
+		if w.SheetByID(op.Sheet) != nil {
+			return nil // concurrent duplicate add: first wins
+		}
+		w.Sheets = slices.Insert(w.Sheets, min(op.Index, len(w.Sheets)), NewSheet(op.Sheet, op.Name))
+		return nil
Relevance

⭐⭐⭐ High

Team often hardens validation for untrusted/persisted inputs (e.g., op props allowlist PR #320;
persisted params checks PR #300).

PR-#320
PR-#300

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
addSheet uses the caller-provided Op.Sheet as the new sheet ID, but validation only checks
non-empty; snapshots are persisted after ops, so abusive IDs are stored and propagated.

lib/sheet/op.go[79-84]
lib/sheet/apply.go[16-23]
lib/sheetdoc/manager.go[100-112]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
With `addSheet`, the system now accepts a *user-controlled* sheet ID (`Op.Sheet`) and stores it directly into the workbook. `Op.Validate()` currently only checks that `Sheet` is non-empty, so attackers can send arbitrarily long IDs (or odd characters) that bloat snapshots and persisted state.
### Issue Context
- `Workbook.Apply()` inserts `NewSheet(op.Sheet, ...)` for `OpAddSheet`.
- Snapshots are persisted frequently (after each submitted op), so oversized IDs directly impact storage and bandwidth.
### Fix Focus Areas
- lib/sheet/op.go[79-84]
- lib/sheet/apply.go[16-23]
### Suggested fix
1. Add strict validation for `Op.Sheet` in `Op.Validate()` (applies to all ops, but is especially important for `addSheet`):
- Maximum length (e.g., 64 or 128 chars).
- Allowed character set (e.g., `^[A-Za-z0-9_-]+$`).
2. Consider also bounding the maximum number of sheets per workbook (separate decision, but relevant for resource abuse).
3. Add unit tests in `lib/sheet/structural_test.go` to assert invalid IDs are rejected.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (3)
9. Unbounded sheet ID accepted 🐞 Bug ⛨ Security
Description
Op.Validate() only requires Op.Sheet to be non-empty, but addSheet persists op.Sheet
verbatim as a new sheet ID that is included in snapshots. A malicious client can create sheets with
extremely large or malformed IDs, inflating persisted snapshots and memory usage (resource abuse/DoS
vector).
Code

lib/sheet/apply.go[R16-23]

+	// Sheet-list ops manage w.Sheets itself and never need an existing sheet.
+	switch op.Type {
+	case OpAddSheet:
+		if w.SheetByID(op.Sheet) != nil {
+			return nil // concurrent duplicate add: first wins
+		}
+		w.Sheets = slices.Insert(w.Sheets, min(op.Index, len(w.Sheets)), NewSheet(op.Sheet, op.Name))
+		return nil
Relevance

⭐⭐⭐ High

Team often hardens validation for untrusted/persisted inputs (e.g., op props allowlist PR #320;
persisted params checks PR #300).

PR-#320
PR-#300

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
addSheet uses the caller-provided Op.Sheet as the new sheet ID, but validation only checks
non-empty; snapshots are persisted after ops, so abusive IDs are stored and propagated.

lib/sheet/op.go[79-84]
lib/sheet/apply.go[16-23]
lib/sheetdoc/manager.go[100-112]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
With `addSheet`, the system now accepts a *user-controlled* sheet ID (`Op.Sheet`) and stores it directly into the workbook. `Op.Validate()` currently only checks that `Sheet` is non-empty, so attackers can send arbitrarily long IDs (or odd characters) that bloat snapshots and persisted state.
### Issue Context
- `Workbook.Apply()` inserts `NewSheet(op.Sheet, ...)` for `OpAddSheet`.
- Snapshots are persisted frequently (after each submitted op), so oversized IDs directly impact storage and bandwidth.
### Fix Focus Areas
- lib/sheet/op.go[79-84]
- lib/sheet/apply.go[16-23]
### Suggested fix
1. Add strict validation for `Op.Sheet` in `Op.Validate()` (applies to all ops, but is especially important for `addSheet`):
 - Maximum length (e.g., 64 or 128 chars).
 - Allowed character set (e.g., `^[A-Za-z0-9_-]+$`).
2. Consider also bounding the maximum number of sheets per workbook (separate decision, but relevant for resource abuse).
3. Add unit tests in `lib/sheet/structural_test.go` to assert invalid IDs are rejected.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


10. Snapshot shares mutable maps ✓ Resolved 🐞 Bug ☼ Reliability
Description
Workbook.Snapshot() stores ColWidths/RowHeights map references directly in the snapshot, so
the snapshot can be mutated after sheetdoc.Manager.Snapshot() unlocks but before sendSheetVars
marshals, creating an inconsistent snapshot relative to the returned head revision.
Code

lib/sheet/snapshot.go[R51-57]

+		ss := SheetSnapshot{Id: s.Id, Name: s.Name, Cells: cells, FrozenRows: s.FrozenRows, FrozenCols: s.FrozenCols}
+		if len(s.ColWidths) > 0 {
+			ss.ColWidths = s.ColWidths
+		}
+		if len(s.RowHeights) > 0 {
+			ss.RowHeights = s.RowHeights
+		}
Evidence
Snapshot assigns the dimension maps by reference; Manager.Snapshot releases its lock before the
snapshot is marshaled into bytes for the wire, allowing concurrent mutations to change the
serialized snapshot without changing the reported head revision.

lib/sheet/snapshot.go[38-60]
lib/sheetdoc/manager.go[138-148]
lib/ws/SheetHandler.go[233-243]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`Workbook.Snapshot()` assigns `s.ColWidths`/`s.RowHeights` directly into the returned snapshot. Because these are reference types, callers can observe later mutations through the snapshot, and there is a real window where the snapshot can become inconsistent with the `head` revision returned by `sheetdoc.Manager.Snapshot()`.
### Issue Context
`sheetdoc.Manager.Snapshot()` returns the snapshot and head under a mutex, but `sendSheetVars()` marshals the snapshot after the mutex is released. If a concurrent op mutates a sheet’s dimension maps between return and marshal, JSON encoding may see a newer map state while still advertising the older `head`.
### Fix Focus Areas
- lib/sheet/snapshot.go[38-60]
- lib/sheetdoc/manager.go[138-148]
- lib/ws/SheetHandler.go[233-243]
### Suggested fix
- In `Workbook.Snapshot()`, deep-copy the new maps before assigning:
- `ss.ColWidths = maps.Clone(s.ColWidths)`
- `ss.RowHeights = maps.Clone(s.RowHeights)`
- (Optional) Consider similarly deep-copying any other mutable reference fields that can be mutated concurrently with snapshot marshalling, or ensure the snapshot is fully materialized into immutable data before releasing locks.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


11. Substring rate-limit misfires 🐞 Bug ☼ Reliability
Description
WS commit rate limiting is now gated by strings.Contains() over the full JSON message, which can
incorrectly apply commit rate limits to non-commit messages whose payload happens to include
USER_CHANGES/SHEET_OP as text.
Code

lib/ws/client.go[R135-145]

+		// CommitRateLimiting only covers commits (USER_CHANGES / SHEET_OP), as
+		// in etherpad-lite. Ephemeral traffic like SHEET_PRESENCE arrives per
+		// keystroke and must not burn the commit budget — a drained budget
+		// silently drops the commit itself and edits are lost.
+		if strings.Contains(decodedMessage, "USER_CHANGES") || strings.Contains(decodedMessage, "SHEET_OP") {
+			retrievedSettings.CommitRateLimiting.LoadTest = retrievedSettings.LoadTest
+			if err := ratelimiter.CheckRateLimit(ratelimiter.IPAddress(c.ClientIP), retrievedSettings.CommitRateLimiting); err != nil {
+				logger.Warn("Rate limit exceeded:", err.Error())
+				continue
+			}
+		}
Evidence
The gating logic checks for substrings in the whole message string; other message types include
user-controlled text fields, so the substring can appear even when the message is not a commit.

lib/ws/client.go[135-145]
lib/models/ws/ChatMessage.go[23-37]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The rate-limit gating uses substring search against the full JSON payload. This can misclassify unrelated message types (e.g. chat messages) if user-controlled text includes the trigger substrings, unexpectedly rate limiting non-commit traffic.
### Issue Context
Some WS message types include free-form text fields. Substring searching the entire serialized JSON is not equivalent to checking the actual message `type` discriminator.
### Fix Focus Areas
- lib/ws/client.go[135-145]
- lib/models/ws/ChatMessage.go[23-37]
### Suggested fix
- Replace substring matching with a lightweight parse of the envelope to extract the real message type (e.g. `data.data.type`), then apply `CommitRateLimiting` only when that type equals `USER_CHANGES` or `SHEET_OP`.
- Keep parsing minimal to avoid extra overhead (e.g. unmarshal into a small struct containing only the relevant nested `type` field).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +174 to +195
// attachResizer adds a drag strip to a header cell edge. Dragging previews
// the size live via inline style and commits once on mouseup via onResize.
private attachResizer(th: HTMLTableCellElement, axis: 'col' | 'row', index: number): void {
if (this.opts.readOnly) return;
const grip = document.createElement('span');
grip.className = axis === 'col' ? 'sheet-resizer-col' : 'sheet-resizer-row';
grip.addEventListener('mousedown', (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
const startPos = axis === 'col' ? e.clientX : e.clientY;
const startSize = axis === 'col' ? th.offsetWidth : th.offsetHeight;
const minSize = axis === 'col' ? 40 : 18;
let size = startSize;
const onMove = (me: MouseEvent) => {
size = Math.max(minSize, startSize + ((axis === 'col' ? me.clientX : me.clientY) - startPos));
this.applyDim(axis, index, size);
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
this.opts.onResize?.(axis, index, size);
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Invalid sheet ops deadlock 🐞 Bug ☼ Reliability

The UI can emit invalid setDimension sizes (>4096) and renameSheet names (>128), which the
server rejects in Op.Validate() but handleSheetOp returns without sending ACCEPT, leaving
SheetCollabClient stuck in committing=true until reload.
Agent Prompt
### Issue description
The client can generate structurally invalid sheet ops (notably `setDimension` sizes above the server’s max and `renameSheet` names exceeding the server’s max). When the server rejects these ops, it does not send an ACCEPT/negative-ACK response, so the client’s collab state machine can remain stuck in a committing state.

### Issue Context
- `setDimension` is generated from drag-resize and has no upper bound.
- `renameSheet` accepts arbitrary prompt input without enforcing the server’s length constraint.
- Server-side validation rejects these inputs.
- WS handler drops the op on error and does not send ACCEPT.
- Client clears `committing` only on ACCEPT.

### Fix Focus Areas
- ui/src/js/sheet/sheetView.ts[174-195]
- ui/src/js/sheet/sheetTabs.ts[45-48]
- lib/sheet/op.go[116-145]
- lib/ws/SheetHandler.go[82-106]
- ui/src/js/sheet/sheetCollabClient.ts[51-69]

### Suggested fix
1. Clamp resize sizes client-side before sending `setDimension`:
   - `size = Math.min(size, 4096)` (and possibly also clamp to >=1, though UI already has minSize).
2. Enforce `renameSheet` constraints client-side before sending:
   - Reject/trim names >128 characters (and optionally show an inline error).
3. (Optional hardening) Add a server-side negative ACK path for rejected ops so the client can clear `committing` and surface an error instead of silently stalling.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread lib/sheet/snapshot.go
Comment on lines +51 to +57
ss := SheetSnapshot{Id: s.Id, Name: s.Name, Cells: cells, FrozenRows: s.FrozenRows, FrozenCols: s.FrozenCols}
if len(s.ColWidths) > 0 {
ss.ColWidths = s.ColWidths
}
if len(s.RowHeights) > 0 {
ss.RowHeights = s.RowHeights
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Snapshot shares mutable maps 🐞 Bug ☼ Reliability

Workbook.Snapshot() stores ColWidths/RowHeights map references directly in the snapshot, so
the snapshot can be mutated after sheetdoc.Manager.Snapshot() unlocks but before sendSheetVars
marshals, creating an inconsistent snapshot relative to the returned head revision.
Agent Prompt
### Issue description
`Workbook.Snapshot()` assigns `s.ColWidths`/`s.RowHeights` directly into the returned snapshot. Because these are reference types, callers can observe later mutations through the snapshot, and there is a real window where the snapshot can become inconsistent with the `head` revision returned by `sheetdoc.Manager.Snapshot()`.

### Issue Context
`sheetdoc.Manager.Snapshot()` returns the snapshot and head under a mutex, but `sendSheetVars()` marshals the snapshot after the mutex is released. If a concurrent op mutates a sheet’s dimension maps between return and marshal, JSON encoding may see a newer map state while still advertising the older `head`.

### Fix Focus Areas
- lib/sheet/snapshot.go[38-60]
- lib/sheetdoc/manager.go[138-148]
- lib/ws/SheetHandler.go[233-243]

### Suggested fix
- In `Workbook.Snapshot()`, deep-copy the new maps before assigning:
  - `ss.ColWidths = maps.Clone(s.ColWidths)`
  - `ss.RowHeights = maps.Clone(s.RowHeights)`
- (Optional) Consider similarly deep-copying any other mutable reference fields that can be mutated concurrently with snapshot marshalling, or ensure the snapshot is fully materialized into immutable data before releasing locks.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread lib/ws/client.go
Comment on lines +135 to +145
// CommitRateLimiting only covers commits (USER_CHANGES / SHEET_OP), as
// in etherpad-lite. Ephemeral traffic like SHEET_PRESENCE arrives per
// keystroke and must not burn the commit budget — a drained budget
// silently drops the commit itself and edits are lost.
if strings.Contains(decodedMessage, "USER_CHANGES") || strings.Contains(decodedMessage, "SHEET_OP") {
retrievedSettings.CommitRateLimiting.LoadTest = retrievedSettings.LoadTest
if err := ratelimiter.CheckRateLimit(ratelimiter.IPAddress(c.ClientIP), retrievedSettings.CommitRateLimiting); err != nil {
logger.Warn("Rate limit exceeded:", err.Error())
continue
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Substring rate-limit misfires 🐞 Bug ☼ Reliability

WS commit rate limiting is now gated by strings.Contains() over the full JSON message, which can
incorrectly apply commit rate limits to non-commit messages whose payload happens to include
USER_CHANGES/SHEET_OP as text.
Agent Prompt
### Issue description
The rate-limit gating uses substring search against the full JSON payload. This can misclassify unrelated message types (e.g. chat messages) if user-controlled text includes the trigger substrings, unexpectedly rate limiting non-commit traffic.

### Issue Context
Some WS message types include free-form text fields. Substring searching the entire serialized JSON is not equivalent to checking the actual message `type` discriminator.

### Fix Focus Areas
- lib/ws/client.go[135-145]
- lib/models/ws/ChatMessage.go[23-37]

### Suggested fix
- Replace substring matching with a lightweight parse of the envelope to extract the real message type (e.g. `data.data.type`), then apply `CommitRateLimiting` only when that type equals `USER_CHANGES` or `SHEET_OP`.
- Keep parsing minimal to avoid extra overhead (e.g. unmarshal into a small struct containing only the relevant nested `type` field).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Rate limit payload substring match 🐞 Bug ☼ Reliability
Description
Client.readPump() applies commit rate limiting using strings.Contains() on the entire JSON
message, so non-commit frames (notably SHEET_PRESENCE) can be incorrectly rate-limited if
user-controlled fields contain "USER_CHANGES" or "SHEET_OP". This can drain the commit budget via
presence-keystroke traffic and cause actual commits to be skipped (continue), resulting in lost
edits.
Code

lib/ws/client.go[R135-145]

+		// CommitRateLimiting only covers commits (USER_CHANGES / SHEET_OP), as
+		// in etherpad-lite. Ephemeral traffic like SHEET_PRESENCE arrives per
+		// keystroke and must not burn the commit budget — a drained budget
+		// silently drops the commit itself and edits are lost.
+		if strings.Contains(decodedMessage, "USER_CHANGES") || strings.Contains(decodedMessage, "SHEET_OP") {
+			retrievedSettings.CommitRateLimiting.LoadTest = retrievedSettings.LoadTest
+			if err := ratelimiter.CheckRateLimit(ratelimiter.IPAddress(c.ClientIP), retrievedSettings.CommitRateLimiting); err != nil {
+				logger.Warn("Rate limit exceeded:", err.Error())
+				continue
+			}
+		}
Relevance

⭐⭐ Medium

WS codebase commonly uses strings.Contains routing; no precedent found requiring JSON type parsing
for rate limiting.

PR-#276
PR-#28
PR-#294

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The rate limiter gates on substring matches in the full JSON message, while presence frames include
user-controlled raw content; therefore a presence message can accidentally (or intentionally)
match the commit substrings and be rate-limited/dropped before handling.

lib/ws/client.go[132-145]
ui/src/js/sheet/sheetEditor.ts[59-66]
lib/models/ws/sheetMessages.go[61-80]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`lib/ws/client.go` currently decides whether to apply `CommitRateLimiting` by doing substring checks against the *entire* websocket JSON payload. Because `SHEET_PRESENCE` includes user-controlled `raw` text, any presence message that happens to contain the substrings `"USER_CHANGES"` or `"SHEET_OP"` will be treated as a commit and can be dropped, which can also exhaust the commit budget and lead to dropped commits.

### Issue Context
- Presence frames contain user input (`raw`) and are sent frequently (per keystroke).
- The rate limit check happens before dispatch/unmarshal and uses `continue` on failure.

### Fix Focus Areas
- lib/ws/client.go[135-145]

### Suggested fix
1. Unmarshal a minimal envelope struct (or otherwise reliably extract the message's logical type) and apply commit rate limiting only when the parsed type is exactly `USER_CHANGES` or `SHEET_OP`.
2. Avoid scanning arbitrary payload strings for routing/limiting decisions; if you keep substring-based routing for legacy reasons, at least make the *rate limiting* decision based on parsed `data.data.type`.
3. Add a regression test: a `SHEET_PRESENCE` frame whose `raw` includes `"SHEET_OP"` must not consume the commit budget / must not be dropped by commit rate limiting.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Unbounded sheet ID accepted 🐞 Bug ⛨ Security
Description
Op.Validate() only requires Op.Sheet to be non-empty, but addSheet persists op.Sheet
verbatim as a new sheet ID that is included in snapshots. A malicious client can create sheets with
extremely large or malformed IDs, inflating persisted snapshots and memory usage (resource abuse/DoS
vector).
Code

lib/sheet/apply.go[R16-23]

+	// Sheet-list ops manage w.Sheets itself and never need an existing sheet.
+	switch op.Type {
+	case OpAddSheet:
+		if w.SheetByID(op.Sheet) != nil {
+			return nil // concurrent duplicate add: first wins
+		}
+		w.Sheets = slices.Insert(w.Sheets, min(op.Index, len(w.Sheets)), NewSheet(op.Sheet, op.Name))
+		return nil
Relevance

⭐⭐⭐ High

Team often hardens validation for untrusted/persisted inputs (e.g., op props allowlist PR #320;
persisted params checks PR #300).

PR-#320
PR-#300

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
addSheet uses the caller-provided Op.Sheet as the new sheet ID, but validation only checks
non-empty; snapshots are persisted after ops, so abusive IDs are stored and propagated.

lib/sheet/op.go[79-84]
lib/sheet/apply.go[16-23]
lib/sheetdoc/manager.go[100-112]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
With `addSheet`, the system now accepts a *user-controlled* sheet ID (`Op.Sheet`) and stores it directly into the workbook. `Op.Validate()` currently only checks that `Sheet` is non-empty, so attackers can send arbitrarily long IDs (or odd characters) that bloat snapshots and persisted state.

### Issue Context
- `Workbook.Apply()` inserts `NewSheet(op.Sheet, ...)` for `OpAddSheet`.
- Snapshots are persisted frequently (after each submitted op), so oversized IDs directly impact storage and bandwidth.

### Fix Focus Areas
- lib/sheet/op.go[79-84]
- lib/sheet/apply.go[16-23]

### Suggested fix
1. Add strict validation for `Op.Sheet` in `Op.Validate()` (applies to all ops, but is especially important for `addSheet`):
  - Maximum length (e.g., 64 or 128 chars).
  - Allowed character set (e.g., `^[A-Za-z0-9_-]+$`).
2. Consider also bounding the maximum number of sheets per workbook (separate decision, but relevant for resource abuse).
3. Add unit tests in `lib/sheet/structural_test.go` to assert invalid IDs are rejected.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ You are approaching your monthly quota for Qodo. Upgrade your plan

Qodo Logo

Comment thread lib/ws/client.go
Comment on lines +135 to +145
// CommitRateLimiting only covers commits (USER_CHANGES / SHEET_OP), as
// in etherpad-lite. Ephemeral traffic like SHEET_PRESENCE arrives per
// keystroke and must not burn the commit budget — a drained budget
// silently drops the commit itself and edits are lost.
if strings.Contains(decodedMessage, "USER_CHANGES") || strings.Contains(decodedMessage, "SHEET_OP") {
retrievedSettings.CommitRateLimiting.LoadTest = retrievedSettings.LoadTest
if err := ratelimiter.CheckRateLimit(ratelimiter.IPAddress(c.ClientIP), retrievedSettings.CommitRateLimiting); err != nil {
logger.Warn("Rate limit exceeded:", err.Error())
continue
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Rate limit payload substring match 🐞 Bug ☼ Reliability

Client.readPump() applies commit rate limiting using strings.Contains() on the entire JSON
message, so non-commit frames (notably SHEET_PRESENCE) can be incorrectly rate-limited if
user-controlled fields contain "USER_CHANGES" or "SHEET_OP". This can drain the commit budget via
presence-keystroke traffic and cause actual commits to be skipped (continue), resulting in lost
edits.
Agent Prompt
### Issue description
`lib/ws/client.go` currently decides whether to apply `CommitRateLimiting` by doing substring checks against the *entire* websocket JSON payload. Because `SHEET_PRESENCE` includes user-controlled `raw` text, any presence message that happens to contain the substrings `"USER_CHANGES"` or `"SHEET_OP"` will be treated as a commit and can be dropped, which can also exhaust the commit budget and lead to dropped commits.

### Issue Context
- Presence frames contain user input (`raw`) and are sent frequently (per keystroke).
- The rate limit check happens before dispatch/unmarshal and uses `continue` on failure.

### Fix Focus Areas
- lib/ws/client.go[135-145]

### Suggested fix
1. Unmarshal a minimal envelope struct (or otherwise reliably extract the message's logical type) and apply commit rate limiting only when the parsed type is exactly `USER_CHANGES` or `SHEET_OP`.
2. Avoid scanning arbitrary payload strings for routing/limiting decisions; if you keep substring-based routing for legacy reasons, at least make the *rate limiting* decision based on parsed `data.data.type`.
3. Add a regression test: a `SHEET_PRESENCE` frame whose `raw` includes `"SHEET_OP"` must not consume the commit budget / must not be dropped by commit rate limiting.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread lib/sheet/apply.go
SamTV12345 and others added 5 commits July 2, 2026 21:33
addSheet/renameSheet/deleteSheet/moveSheet manage the workbook sheet
list (duplicate add and last-sheet delete converge as no-ops).
setDimension stores sparse ColWidths/RowHeights per sheet; setFreeze
stores FrozenRows/FrozenCols (0/1). Structural row/col ops shift the
dimension maps; Transform shifts setDimension indices on the same axis.
Ops on a deleted sheet are silent no-ops so late ops cannot poison the
ordered-log replay. Snapshot round-trips the new metadata.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
op.ts wire types, workbookState.applyOp and transform port the Go
semantics: duplicate add / last-sheet delete / deleted-sheet ops
converge as no-ops, dims shift under structural ops, setDimension
indices transform on the matching axis. Snapshot loads the metadata.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DomSheetView grows to 200x52 (ponytail: DOM-per-cell; virtualization is
the upgrade path), renders sparse col/row pixel overrides, adds header
drag-resize grips committing setDimension, and sticky freeze panes via
border-separate + position:sticky driven by CSS vars. Bottom tabs bar
(add/rename via prompt, delete via context menu, drag reorder) drives
the sheet-list ops. Toolbar gains A-Z/Z-A sort (batch setCell with
fill-style formula ref shifts), freeze toggles, and a client-local
filter dropdown over the focused column (row hiding, no ops).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Also: the filter dropdown repopulates on focus as well as mousedown so
keyboard users (and synthetic selection) get fresh values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Snapshot(): clone ColWidths/RowHeights instead of aliasing live maps
  (snapshot is consumed after the doc lock is released; races with Apply)
- resize: only commit setDimension when the size actually changed
- resize: row min matches the 22px CSS floor (table cells treat height
  as min-height, sub-22px rows never rendered)
- workbookState.applyOp(setDimension): mirror server validation
  (axis col/row, integer size 1..4096) instead of storing 0/misrouted axes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@SamTV12345
SamTV12345 force-pushed the feat/excel-m4-structural branch from 09ff725 to 1c3d824 Compare July 2, 2026 19:33
@SamTV12345
SamTV12345 merged commit c6416a8 into main Jul 2, 2026
4 checks passed
@SamTV12345
SamTV12345 deleted the feat/excel-m4-structural branch July 2, 2026 19:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant