Skip to content

Commit 40b9c2c

Browse files
authored
Reject non-canonical trailing-dot tree paths in resolveChildPath (#76)
A crafted WebSocket frame `children.0..selected=true` resolved to a valid in-range node: JawsSetPath's CutSuffix(".selected") left nodePath "children.0.", and resolveChildPath consumed "children"/"0" in one iteration and exited with the trailing empty segment unvisited, returning Children[0] with no error. The server then mutated the node while JawsPathSet broadcast the non-canonical "children.0." verbatim as the jawstreeSetPath id. No peer's rendered node uses that id (Node.Walk emits the canonical "children.0"), so the visual selection was silently dropped on every peer, diverging their rendered tree from server state with no error. The per-index canonical check added earlier only inspects index segments and never sees an empty final segment, so it could not catch this. Reject a consumed-but-empty trailing segment as well (Cut reports the separator via its third return value), which closes the trailing-dot gap while keeping the cheap alloc-free per-index check. Add the failing rejection cases (and canonical-path regressions) to the gate test table, plus BenchmarkResolveChildPath as a regression guard. The resolver was chosen by benchmarking three candidates (this incremental check vs a canonical round-trip via strings.Builder vs a regex validator); the incremental check was fastest and the only one that stays allocation-free on accepted paths. Accepted paths are unchanged from before (zero allocations, within a few ns); the added cost is only on rejected adversarial input, where an error is now correctly returned.
1 parent 8ba762f commit 40b9c2c

3 files changed

Lines changed: 103 additions & 10 deletions

File tree

‎jawstree/node.go‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -136,26 +136,35 @@ func (node *Node) JawsSetPath(elem *jaws.Element, jsPath string, value any) (err
136136
// nil-targeting segments are rejected, so a client can neither grow the slice nor
137137
// address a node that does not exist.
138138
//
139-
// Indices must be in canonical decimal form (no leading '+', '-' or zeros) so the
140-
// index a client sends round-trips to the same string [Node.Walk] emits as the
141-
// node ID. A non-canonical but in-range index would mutate the server node yet be
142-
// echoed verbatim as the [Node.JawsPathSet] broadcast "id", which no peer's
143-
// rendered node matches, diverging peer state from the server.
139+
// The whole path must be in canonical form so that it round-trips to the exact
140+
// string [Node.Walk] emits as the node ID: segments strictly alternate "children"
141+
// and a canonical decimal index (no leading '+', '-' or zeros), joined by single
142+
// dots with no empty segment from a leading, trailing or doubled '.'. A path that
143+
// resolves to a valid in-range node but is not canonical — for example a trailing
144+
// dot ("children.0.") or a non-canonical index ("children.+0") — would mutate the
145+
// server node yet be echoed verbatim as the [Node.JawsPathSet] broadcast "id",
146+
// which no peer's rendered node matches, diverging peer state from the server.
147+
//
148+
// Each index is checked against its canonical form (strconv.Itoa is allocation-free
149+
// for indices below 100). The trailing-dot case is the one a per-index check alone
150+
// misses: it leaves an empty final segment the pair-wise loop never visits, so we
151+
// reject when Cut reports a separator was consumed ('more') but nothing follows it.
144152
func (node *Node) resolveChildPath(nodePath string) (*Node, error) {
145153
cur := node
146-
for nodePath != "" {
154+
for rest := nodePath; rest != ""; {
147155
var seg, idxStr string
148-
seg, nodePath, _ = strings.Cut(nodePath, ".")
156+
var more bool
157+
seg, rest, _ = strings.Cut(rest, ".")
149158
if seg != "children" {
150159
return nil, fmt.Errorf("%w: unexpected path segment %q", ErrPathRejected, seg)
151160
}
152-
idxStr, nodePath, _ = strings.Cut(nodePath, ".")
161+
idxStr, rest, more = strings.Cut(rest, ".")
153162
idx, err := strconv.Atoi(idxStr)
154163
if err != nil || idx < 0 || idx >= len(cur.Children) || cur.Children[idx] == nil {
155164
return nil, fmt.Errorf("%w: child index %q out of range", ErrPathRejected, idxStr)
156165
}
157-
if strconv.Itoa(idx) != idxStr {
158-
return nil, fmt.Errorf("%w: non-canonical child index %q", ErrPathRejected, idxStr)
166+
if strconv.Itoa(idx) != idxStr || (more && rest == "") {
167+
return nil, fmt.Errorf("%w: non-canonical path %q", ErrPathRejected, nodePath)
159168
}
160169
cur = cur.Children[idx]
161170
}

‎jawstree/node_benchmark_test.go‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package jawstree
2+
3+
import "testing"
4+
5+
var resolveChildPathBenchSink *Node
6+
7+
// BenchmarkResolveChildPath guards the canonical child-path resolver against
8+
// regression across shallow, deep and rejected (trailing-dot) paths. The resolver
9+
// runs on every inbound tree-selection event, so it must stay allocation-free on
10+
// the accepted paths.
11+
func BenchmarkResolveChildPath(b *testing.B) {
12+
// Build a 3-deep tree so the deep path resolves.
13+
leaf := func(n string) *Node { return &Node{Name: n} }
14+
root := &Node{Name: "root", Children: []*Node{
15+
{Name: "a", Children: []*Node{
16+
leaf("a0"),
17+
{Name: "a1", Children: []*Node{leaf("a1.0"), leaf("a1.1"), leaf("a1.2")}},
18+
}},
19+
leaf("b"),
20+
}}
21+
22+
cases := []struct {
23+
name string
24+
path string
25+
}{
26+
{"shallow", "children.1"},
27+
{"deep", "children.0.children.1.children.2"},
28+
{"rejected", "children.0.children.1.children.2."}, // trailing dot
29+
}
30+
31+
for _, c := range cases {
32+
b.Run(c.name, func(b *testing.B) {
33+
b.ReportAllocs()
34+
b.ResetTimer()
35+
for i := 0; i < b.N; i++ {
36+
n, _ := root.resolveChildPath(c.path)
37+
resolveChildPathBenchSink = n
38+
}
39+
})
40+
}
41+
}

‎jawstree/node_test.go‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,49 @@ func TestNode_JawsSetPath_Gate(t *testing.T) {
148148
}
149149
}
150150
})
151+
152+
// A non-canonical path that still resolves to a valid in-range node (an empty
153+
// trailing/leading/embedded segment) must be rejected. Such a path otherwise
154+
// mutates the server node while JawsPathSet echoes the non-canonical path
155+
// verbatim as the broadcast id, which no peer's rendered (canonical) node id
156+
// matches, silently desyncing peer selection. "children.0..selected" is the
157+
// regression: it resolves to Children[0] yet is not the canonical
158+
// "children.0.selected" that Node.Walk emits.
159+
t.Run("rejects non-canonical paths resolving to a valid node", func(t *testing.T) {
160+
for _, path := range []string{
161+
"children.0..selected", // trailing dot -> nodePath "children.0."
162+
".children.0.selected", // leading empty segment
163+
"children..0.selected", // embedded empty segment
164+
} {
165+
root := newTree()
166+
if err := root.JawsSetPath(nil, path, true); !errors.Is(err, jawstree.ErrPathRejected) {
167+
t.Errorf("path %q: expected ErrPathRejected, got %v", path, err)
168+
}
169+
if root.Children[0].Selected {
170+
t.Errorf("path %q: Children[0].Selected was mutated despite rejection", path)
171+
}
172+
}
173+
})
174+
175+
// The fix must not reject legitimate canonical paths, including nested ones.
176+
t.Run("accepts canonical paths including nested", func(t *testing.T) {
177+
root := &jawstree.Node{Name: "root", Children: []*jawstree.Node{
178+
{Name: "a", Children: []*jawstree.Node{{Name: "a0"}, {Name: "a1"}}},
179+
{Name: "b"},
180+
}}
181+
if err := root.JawsSetPath(nil, "children.0.children.1.selected", true); err != nil {
182+
t.Fatalf("nested canonical path: %v", err)
183+
}
184+
if !root.Children[0].Children[1].Selected {
185+
t.Error("nested node was not selected")
186+
}
187+
if err := root.JawsSetPath(nil, "children.1.selected", true); err != nil {
188+
t.Fatalf("canonical path: %v", err)
189+
}
190+
if !root.Children[1].Selected {
191+
t.Error("node was not selected")
192+
}
193+
})
151194
}
152195

153196
// TestNode_NilChildGuards exercises the defensive nil-child guards in marshalJSON

0 commit comments

Comments
 (0)