Skip to content

Commit 1f66bb3

Browse files
egeozcanclaude
andcommitted
fix(note-blocks): address multi-agent review findings
Closes the 14 findings from the branch review: - shared calendar (read-only view) was missing two fixes that the editor calendar got: mirror the `fetchRange` getter so events on the month grid's adjacent-month padding cells load, and normalize a legacy/unknown stored `view` (e.g. "week") to "month" so it never renders blank - make the "first text block" selection deterministic with a `position ASC, id ASC` tiebreak everywhere it is queried, closing a Postgres description-wipe path when two blocks share a position - query params are edited as a stable {id,key,value} row model so renaming a key no longer destroys the value input mid-edit, and new param keys are guaranteed unique - refresh the block returned by CreateBlock after a post-commit rebalance so API/CLI consumers don't get a stale position - clear _pendingUpdates on debounced-save success and on delete so the unload flush can't re-PUT already-saved or deleted blocks - strip Unicode format/zero-width chars (\p{Cf}) in the markdown link scheme check so they can't split a dangerous scheme past the allowlist Tests: ICS scheme allowlist + redirect-to-file:// SSRF block, auto-rebalance order preservation, tied-position description determinism, _generateBetween JS/Go parity, and Unicode link-scheme XSS cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 653f5d9 commit 1f66bb3

12 files changed

Lines changed: 251 additions & 57 deletions

File tree

application_context/basic_entity_context.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ func (w *EntityWriter[T]) UpdateDescription(id uint, description string) error {
6767
if err := tx.Table("note_blocks").
6868
Select("id").
6969
Where("note_id = ? AND type = ?", id, "text").
70-
Order("position ASC").
70+
Order("position ASC, id ASC").
7171
Limit(1).
7272
Find(&blocks).Error; err != nil {
7373
return err

application_context/block_bugfix_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package application_context
22

33
import (
44
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
57
"testing"
68

79
"github.com/stretchr/testify/assert"
@@ -214,6 +216,76 @@ func TestCreateBlock_AutoRebalancesLongPositions(t *testing.T) {
214216
}
215217
}
216218

219+
// Review #13: auto-rebalance triggered by a create must PRESERVE block order, not
220+
// just shorten positions. Uses 3 ordered blocks so order is actually verifiable.
221+
func TestCreateBlock_AutoRebalancePreservesOrder(t *testing.T) {
222+
ctx := createBlockTestContext(t)
223+
note, err := createTestNote(ctx, "n")
224+
require.NoError(t, err)
225+
226+
a, err := ctx.CreateBlock(&query_models.NoteBlockEditor{NoteID: note.ID, Type: "divider", Position: "d", Content: json.RawMessage(`{}`)})
227+
require.NoError(t, err)
228+
b, err := ctx.CreateBlock(&query_models.NoteBlockEditor{NoteID: note.ID, Type: "divider", Position: "h", Content: json.RawMessage(`{}`)})
229+
require.NoError(t, err)
230+
// "zzzzzzzzzzzz" (12 chars) sorts last and exceeds the rebalance threshold,
231+
// so creating it triggers the auto-rebalance over all three blocks.
232+
c, err := ctx.CreateBlock(&query_models.NoteBlockEditor{NoteID: note.ID, Type: "divider", Position: "zzzzzzzzzzzz", Content: json.RawMessage(`{}`)})
233+
require.NoError(t, err)
234+
235+
blocks, err := ctx.GetBlocksForNote(note.ID)
236+
require.NoError(t, err)
237+
require.Len(t, blocks, 3)
238+
assert.Equal(t, a.ID, blocks[0].ID, "order A,B,C must be preserved through auto-rebalance")
239+
assert.Equal(t, b.ID, blocks[1].ID)
240+
assert.Equal(t, c.ID, blocks[2].ID)
241+
for _, bl := range blocks {
242+
assert.LessOrEqual(t, len(bl.Position), 8, "positions must be rebalanced under the threshold")
243+
}
244+
}
245+
246+
// Review #3: two text blocks at the SAME position must resolve the first text
247+
// block deterministically (position ASC, id ASC), so the older non-empty block
248+
// keeps owning the description instead of an empty later block wiping it.
249+
func TestCreateBlock_TiedPositionKeepsDescriptionDeterministic(t *testing.T) {
250+
ctx := createBlockTestContext(t)
251+
note, err := createTestNote(ctx, "n")
252+
require.NoError(t, err)
253+
254+
_, err = ctx.CreateBlock(&query_models.NoteBlockEditor{NoteID: note.ID, Type: "text", Position: "d", Content: json.RawMessage(`{"text":"Hello"}`)})
255+
require.NoError(t, err)
256+
_, err = ctx.CreateBlock(&query_models.NoteBlockEditor{NoteID: note.ID, Type: "text", Position: "d", Content: json.RawMessage(`{"text":""}`)})
257+
require.NoError(t, err)
258+
259+
var n models.Note
260+
require.NoError(t, ctx.db.First(&n, note.ID).Error)
261+
assert.Equal(t, "Hello", n.Description,
262+
"with the id-ASC tiebreak the older 'Hello' block is first, so the description survives")
263+
}
264+
265+
// Review #6: the ICS scheme allowlist accepts http(s) and rejects everything else.
266+
func TestICS_AllowedScheme(t *testing.T) {
267+
for _, ok := range []string{"http://x/c.ics", "https://x/c.ics", "HTTPS://X/C.ics"} {
268+
assert.True(t, allowedICSScheme(ok), "scheme should be allowed: %s", ok)
269+
}
270+
for _, bad := range []string{"file:///etc/passwd", "gopher://x/", "ftp://x/c.ics", "", "javascript:alert(1)", "not a url"} {
271+
assert.False(t, allowedICSScheme(bad), "scheme should be rejected: %s", bad)
272+
}
273+
}
274+
275+
// Review #6: the runtime fetch must re-validate redirect targets, so an http(s)
276+
// URL that 302-redirects to a non-http(s) scheme is blocked (SSRF defense).
277+
func TestFetchICS_RejectsRedirectToNonHttpScheme(t *testing.T) {
278+
ctx := createBlockTestContext(t)
279+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
280+
http.Redirect(w, r, "file:///etc/passwd", http.StatusFound)
281+
}))
282+
defer srv.Close()
283+
284+
_, _, err := ctx.fetchAndCacheICS(srv.URL, nil)
285+
require.Error(t, err)
286+
assert.Contains(t, err.Error(), "redirect to non-http(s) URL blocked")
287+
}
288+
217289
// B4: CreateBlock must also reject explicit null content (non-empty "null").
218290
func TestCreateBlock_RejectsNullContent(t *testing.T) {
219291
ctx := createBlockTestContext(t)

application_context/block_context.go

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,11 @@ func (ctx *MahresourcesContext) CreateBlock(editor *query_models.NoteBlockEditor
132132
}
133133
}
134134

135-
ctx.maybeRebalanceBlockPositions(editor.NoteID)
135+
if ctx.maybeRebalanceBlockPositions(editor.NoteID) {
136+
// A rebalance rewrote every position; refresh the returned block so its
137+
// Position reflects the persisted value (API/CLI consumers read it).
138+
ctx.db.First(&block, block.ID)
139+
}
136140

137141
return &block, nil
138142
}
@@ -157,7 +161,7 @@ func (ctx *MahresourcesContext) GetBlocksForNote(noteID uint) ([]models.NoteBloc
157161
return []models.NoteBlock{}, nil
158162
}
159163
var blocks []models.NoteBlock
160-
err := ctx.db.Where("note_id = ?", noteID).Order("position ASC").Find(&blocks).Error
164+
err := ctx.db.Where("note_id = ?", noteID).Order("position ASC, id ASC").Find(&blocks).Error
161165
return blocks, err
162166
}
163167

@@ -450,7 +454,7 @@ func seedFirstTextBlockFromDescription(db *gorm.DB, editor *query_models.NoteBlo
450454
func syncFirstTextBlockToDescriptionTx(tx *gorm.DB, noteID uint) error {
451455
var blocks []models.NoteBlock
452456
if err := tx.Where("note_id = ? AND type = ?", noteID, "text").
453-
Order("position ASC").Limit(1).Find(&blocks).Error; err != nil {
457+
Order("position ASC, id ASC").Limit(1).Find(&blocks).Error; err != nil {
454458
return err
455459
}
456460

@@ -476,21 +480,24 @@ const rebalanceThreshold = 8
476480

477481
// maybeRebalanceBlockPositions auto-rebalances a note's block positions when any
478482
// position string has grown past rebalanceThreshold. Best-effort and called
479-
// after a create/reorder commits; failures are logged, not propagated.
480-
func (ctx *MahresourcesContext) maybeRebalanceBlockPositions(noteID uint) {
483+
// after a create/reorder commits; failures are logged, not propagated. Returns
484+
// true if a rebalance actually ran (so callers can refresh stale in-memory rows).
485+
func (ctx *MahresourcesContext) maybeRebalanceBlockPositions(noteID uint) bool {
481486
var positions []string
482487
if err := ctx.db.Model(&models.NoteBlock{}).
483488
Where("note_id = ?", noteID).
484489
Pluck("position", &positions).Error; err != nil {
485490
log.Printf("Warning: failed to read positions for rebalance check on note %d: %v", noteID, err)
486-
return
491+
return false
487492
}
488493
if !lib.NeedsRebalancing(positions, rebalanceThreshold) {
489-
return
494+
return false
490495
}
491496
if err := ctx.RebalanceBlockPositions(noteID); err != nil {
492497
log.Printf("Warning: auto-rebalance failed for note %d: %v", noteID, err)
498+
return false
493499
}
500+
return true
494501
}
495502

496503
// RebalanceBlockPositions normalizes block positions for a note to prevent position string growth.
@@ -501,7 +508,7 @@ func (ctx *MahresourcesContext) RebalanceBlockPositions(noteID uint) error {
501508
return gorm.ErrRecordNotFound
502509
}
503510
var blocks []models.NoteBlock
504-
if err := ctx.db.Where("note_id = ?", noteID).Order("position ASC").Find(&blocks).Error; err != nil {
511+
if err := ctx.db.Where("note_id = ?", noteID).Order("position ASC, id ASC").Find(&blocks).Error; err != nil {
505512
return err
506513
}
507514

application_context/export_context.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1494,7 +1494,7 @@ func (ctx *MahresourcesContext) loadNotePayload(id uint, plan *exportPlan) (*arc
14941494
Preload("Groups").
14951495
Preload("NoteType").
14961496
Preload("Blocks", func(db *gorm.DB) *gorm.DB {
1497-
return db.Order("position ASC")
1497+
return db.Order("position ASC, id ASC")
14981498
}).
14991499
First(&n, id).Error; err != nil {
15001500
return nil, err

application_context/note_context.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ func (ctx *MahresourcesContext) CreateOrUpdateNote(noteQuery *query_models.NoteE
189189
// Sync description to first text block if blocks exist (backward compatibility)
190190
if noteQuery.ID != 0 {
191191
var blocks []models.NoteBlock
192-
if err := tx.Where("note_id = ? AND type = ?", note.ID, "text").Order("position ASC").Limit(1).Find(&blocks).Error; err == nil && len(blocks) > 0 {
192+
if err := tx.Where("note_id = ? AND type = ?", note.ID, "text").Order("position ASC, id ASC").Limit(1).Find(&blocks).Error; err == nil && len(blocks) > 0 {
193193
content, _ := json.Marshal(map[string]string{"text": noteQuery.Description})
194194
tx.Model(&blocks[0]).Update("content", content)
195195
}
@@ -227,7 +227,7 @@ func (ctx *MahresourcesContext) GetNote(id uint) (*models.Note, error) {
227227

228228
return &note, ctx.db.Preload(clause.Associations).
229229
Preload("Blocks", func(db *gorm.DB) *gorm.DB {
230-
return db.Order("position ASC")
230+
return db.Order("position ASC, id ASC")
231231
}).
232232
First(&note, id).Error
233233
}
@@ -379,7 +379,7 @@ func (ctx *MahresourcesContext) GetNoteByShareToken(token string) (*models.Note,
379379
var note models.Note
380380
err := ctx.db.
381381
Preload("Blocks", func(db *gorm.DB) *gorm.DB {
382-
return db.Order("position ASC")
382+
return db.Order("position ASC, id ASC")
383383
}).
384384
Preload("Resources").
385385
Preload("NoteType").

public/dist/main.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* Guards the JS positionBetween/_generateBetween port against drift from the Go
3+
* reference (lib/position.go). The branch ported two Go branches into
4+
* _generateBetween to fix an ordering bug (positionBetween("a","aa") used to
5+
* return "aan" > "aa"). Same extract-and-eval approach as the renderMarkdown
6+
* test, since these are methods on the Alpine data factory.
7+
*/
8+
import { readFileSync } from 'fs';
9+
import { resolve, dirname } from 'path';
10+
import { fileURLToPath } from 'url';
11+
import { describe, it, expect } from 'vitest';
12+
13+
const __dirname = dirname(fileURLToPath(import.meta.url));
14+
const src = readFileSync(resolve(__dirname, './blockEditor.js'), 'utf-8');
15+
16+
function extractMethod(name: string): string {
17+
const re = new RegExp(name + '\\(before, after\\)\\s*\\{[\\s\\S]*?\\n\\s{4}\\},');
18+
const m = src.match(re);
19+
if (!m) throw new Error('could not extract ' + name + ' from blockEditor.js');
20+
return m[0]
21+
.replace(name + '(before, after)', 'function ' + name + '(before, after)')
22+
.replace(/,\s*$/, '')
23+
.replace(/this\._generateBetween/g, '_generateBetween');
24+
}
25+
26+
const positionBetween = new Function(
27+
`const MIN_CHAR = 'a'.charCodeAt(0);
28+
const MAX_CHAR = 'z'.charCodeAt(0);
29+
${extractMethod('_generateBetween')}
30+
${extractMethod('positionBetween')}
31+
return positionBetween;`
32+
)() as (before: string, after: string) => string;
33+
34+
describe('positionBetween / _generateBetween — JS/Go parity', () => {
35+
// Values verified against Go lib.PositionBetween.
36+
it('matches Go for the regressed adjacent pair ("a","aa") -> "aa" (not "aan")', () => {
37+
expect(positionBetween('a', 'aa')).toBe('aa');
38+
});
39+
it('matches Go for ("b","ba") -> "ba"', () => {
40+
expect(positionBetween('b', 'ba')).toBe('ba');
41+
});
42+
it('matches Go for ("a","b") -> "an"', () => {
43+
expect(positionBetween('a', 'b')).toBe('an');
44+
});
45+
it('returns "n" for the empty/empty (first block) case', () => {
46+
expect(positionBetween('', '')).toBe('n');
47+
});
48+
49+
it('keeps before < result, and result <= after (< after when not adjacent)', () => {
50+
const pairs: [string, string][] = [
51+
['a', 'b'], ['a', 'aa'], ['b', 'ba'], ['n', 'na'], ['a', 'c'],
52+
['aa', 'ab'], ['m', 'n'], ['', 'n'], ['n', ''], ['a', ''],
53+
];
54+
for (const [before, after] of pairs) {
55+
const r = positionBetween(before, after);
56+
expect(r > before).toBe(true);
57+
// after === '' is an open upper bound ("past z"), so skip that check.
58+
if (after !== '') expect(r <= after).toBe(true);
59+
}
60+
});
61+
62+
it('stays strictly ordered across repeated end-appends (the default add path)', () => {
63+
let last = 'n';
64+
let prev = '';
65+
for (let i = 0; i < 50; i++) {
66+
const next = positionBetween(last, '');
67+
expect(next > last).toBe(true);
68+
prev = last;
69+
last = next;
70+
}
71+
expect(prev).not.toBe(last);
72+
});
73+
});

src/components/blockEditor-render-markdown.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,18 @@ describe('XSS: link scheme sanitization (control-char bypass)', () => {
124124
expect(renderMarkdown('[x](vbscript:msgbox)')).not.toMatch(/<a\s/);
125125
});
126126

127+
it('blocks javascript: split by a zero-width space (U+200B)', () => {
128+
expect(renderMarkdown('[x](java​script:foo)')).not.toMatch(/<a\s/);
129+
});
130+
131+
it('blocks javascript: split by a soft hyphen (U+00AD)', () => {
132+
expect(renderMarkdown('[x](java­script:foo)')).not.toMatch(/<a\s/);
133+
});
134+
135+
it('blocks javascript: split by a word joiner (U+2060)', () => {
136+
expect(renderMarkdown('[x](java⁠script:foo)')).not.toMatch(/<a\s/);
137+
});
138+
127139
it('still renders safe http/https/mailto links', () => {
128140
expect(renderMarkdown('[x](https://example.com)')).toMatch(/<a href="https:\/\/example\.com"/);
129141
expect(renderMarkdown('[x](http://example.com)')).toMatch(/<a href="http:\/\/example\.com"/);

src/components/blockEditor.js

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,11 @@ export function blockEditor(noteId, initialBlocks = []) {
7777
// A denylist on the raw string (.trim().startsWith('javascript:')) is
7878
// bypassable: browsers strip whitespace/control chars embedded in a URL
7979
// scheme, so "java\tscript:" resolves to "javascript:". Strip all
80-
// whitespace + ASCII control chars first, then ALLOWLIST safe schemes
81-
// (or relative URLs) rather than denylisting dangerous ones.
82-
const cleaned = href.replace(/[\s\x00-\x1f]+/g, '').toLowerCase();
80+
// whitespace, ASCII control chars, AND Unicode format/zero-width chars
81+
// (\p{Cf}: U+200B, U+00AD, ... which JS \s does NOT cover) first, then
82+
// ALLOWLIST safe schemes (or relative URLs) rather than denylisting.
83+
const STRIP = /[\s\x00-\x1f]|\p{Cf}/gu;
84+
const cleaned = href.replace(STRIP, '').toLowerCase();
8385
const hasScheme = /^[a-z][a-z0-9+.\-]*:/.test(cleaned);
8486
const safe = !hasScheme
8587
|| cleaned.startsWith('http:')
@@ -90,7 +92,9 @@ export function blockEditor(noteId, initialBlocks = []) {
9092
return text;
9193
}
9294
// Defang control chars that survived in the original href so they
93-
// cannot reconstitute a dangerous scheme in the attribute. (Quotes are
95+
// cannot reconstitute a dangerous scheme in the attribute. Only ASCII
96+
// control chars are browser-stripped from schemes; we deliberately keep
97+
// regular whitespace here so legitimate URLs aren't mangled. (Quotes are
9498
// already HTML-escaped above, blocking attribute breakout.)
9599
const safeHref = href.replace(/[\x00-\x1f]/g, '');
96100
return `<a href="${safeHref}" class="text-blue-600 hover:underline" target="_blank" rel="noopener">${text}</a>`;
@@ -346,6 +350,9 @@ export function blockEditor(noteId, initialBlocks = []) {
346350
this.blocks[idx] = { ...this.blocks[idx], content: updated.content, updatedAt: updated.updatedAt };
347351
}
348352
delete this._prevContent[blockId];
353+
// Clear the pending entry unless a newer edit superseded this save, so
354+
// the unload flush doesn't redundantly re-PUT already-persisted content.
355+
if (this._pendingUpdates[blockId] === content) delete this._pendingUpdates[blockId];
349356
} catch (err) {
350357
// Roll back the optimistic content so the UI reflects server truth and
351358
// the user is not misled into thinking an unsaved edit persisted.
@@ -398,6 +405,10 @@ export function blockEditor(noteId, initialBlocks = []) {
398405
throw new Error(errorData.error || `Failed to delete block: ${res.status}`);
399406
}
400407
this.blocks = this.blocks.filter(b => b.id !== blockId);
408+
// Drop any pending optimistic state so the unload flush can never re-PUT
409+
// a now-deleted block (which the server would reject).
410+
delete this._pendingUpdates[blockId];
411+
delete this._prevContent[blockId];
401412
this.announce('Block deleted');
402413
// Move focus to a sensible neighbor (the block now occupying the deleted
403414
// slot, else the add-block trigger) so keyboard users are not stranded.

0 commit comments

Comments
 (0)