fix(17): CR-01 ignore an item URL pattern override that is not a path

signpost.Registry.Pattern now takes an item override only when it starts
with a single "/" and holds no surrounding whitespace or control
characters (signpost.ValidItemPattern); a javascript: URL or a
protocol-relative //host falls through to the next layer.
This commit is contained in:
2026-10-12 21:25:30 +02:00
parent d3a7f798f0
commit 964f1a7f11
4 changed files with 50 additions and 2 deletions

View File

@@ -111,6 +111,8 @@ Headless sites decide their own URLs, so a type's URL comes from a pattern in la
3. For a list type with `PatternFrom`, the host's override of the record type it lists.
4. The type's `DefaultPattern`.
An item override that is not a path is ignored: it must start with a single `/` and hold no surrounding whitespace or control characters (`signpost.ValidItemPattern`). A `javascript:` URL or a protocol-relative `//host` in a stored menu entry or sitemap row therefore falls through to the next layer instead of reaching the resolved URL.
Layer 3 is why a list type declares `PatternFrom`. A host that moves posts to `/news/:slug` sets that once, for `acme-post`, and the children of "all posts" follow, because `acme-all-posts` names `acme-post` and has no override of its own. Give a list type the same `DefaultPattern` as the type it lists, so both agree without any host configuration.
The host application sets its overrides in `config/menu_items.yaml`:

View File

@@ -23,7 +23,7 @@ Use cases:
## Features
- Boot-time collection in plugin order, then declaration order. Every error is prefixed `signpost:` and fails the boot: an empty key, a key two plugins register (the message names the key and both plugin IDs), a nil `Resolve`, a `DefaultPattern` that is empty or does not start with `/`, a `PatternFrom` that names an unknown type, the type itself or a type that sets `PatternFrom` too, and a host override for an unknown type or one that does not start with `/`.
- URL patterns in layers (`signpost.Registry.Pattern`): the item's own override, else the host override of the type, else, for a list type that sets `PatternFrom`, the host override of the record type it lists, else the type's `DefaultPattern`. A host that changes the post URL once therefore also changes the children of every list of posts, unless the list type itself is overridden.
- URL patterns in layers (`signpost.Registry.Pattern`): the item's own override, else the host override of the type, else, for a list type that sets `PatternFrom`, the host override of the record type it lists, else the type's `DefaultPattern`. A host that changes the post URL once therefore also changes the children of every list of posts, unless the list type itself is overridden. An item override is taken only when it is a path (`signpost.ValidItemPattern`): it starts with a single `/` and has no surrounding whitespace or control characters. Any other override, such as a `javascript:` URL or a protocol-relative `//host`, is ignored and the next layer applies.
- `signpost.Registry.Resolve` hands the layered pattern to the type's `Resolve` in `pact.MenuItemResolve.Pattern`. The result, `pact.ResolvedMenuItem`, carries the title, URL, modification time, nested items, and the alternates per locale as an ordered `[]pact.MenuItemAlternate`, never a map.
- `signpost.ReferenceOptions` flattens a type's reference tree depth first into dropdown options with a `Depth`, and answers `Applies` false for a type that takes no reference or that no plugin registers.
@@ -94,6 +94,7 @@ func (c entriesController) DependentOptions(ctx context.Context, field string, v
| `signpost.Registry.Types` | Every registered type, in plugin order and then declaration order. |
| `signpost.Registry.Type` | The type registered under a key. |
| `signpost.Registry.Pattern` | The layered URL pattern of an item: item override, host override, the record type's host override for a list type, default. |
| `signpost.ValidItemPattern` | Whether a pattern may serve as an item override: a path starting with a single `/`, without surrounding whitespace or control characters. Plugins use it to refuse such a pattern on save. |
| `signpost.Registry.Resolve` | Resolves one item through its type with the layered pattern; an unknown type is an error. |
| `signpost.ReferenceOptions` | The dependent dropdown options of a type's references, flattened with their depth; `Applies` false for a type without references. |
| `signpost.ConfigKey` | `menu_items.patterns`, the host configuration key of the pattern overrides. |

View File

@@ -153,8 +153,13 @@ func (r *Registry) Type(key string) (pact.MenuItemType, bool) {
// else, for a list type that sets PatternFrom, the host override of that
// record type, else the type's DefaultPattern. An unknown key without an item
// override answers "".
//
// The item override is taken only when it is a path (ValidItemPattern): one
// that is not, such as a javascript: URL or a protocol-relative //host, is
// ignored and the next layer applies, so stored item data cannot point a
// resolved URL at another scheme or host.
func (r *Registry) Pattern(key, itemOverride string) string {
if p := strings.TrimSpace(itemOverride); p != "" {
if p := strings.TrimSpace(itemOverride); p != "" && ValidItemPattern(p) {
return p
}
if r == nil {
@@ -175,6 +180,20 @@ func (r *Registry) Pattern(key, itemOverride string) string {
return t.DefaultPattern
}
// ValidItemPattern reports whether p may serve as an item's URL pattern
// override: it starts with a single "/" (not "//" or "/\", which browsers
// read as another host) and holds no whitespace at its ends and no control
// characters, which browsers strip before reading a URL.
func ValidItemPattern(p string) bool {
if p == "" || p[0] != '/' || strings.HasPrefix(p, "//") || strings.HasPrefix(p, `/\`) {
return false
}
if strings.TrimSpace(p) != p {
return false
}
return !strings.ContainsFunc(p, func(r rune) bool { return r < 0x20 || r == 0x7f })
}
// Resolve resolves one item of type key through the type's Resolve, handing
// it the layered pattern (Pattern(key, itemPattern)). An unknown key is an
// error.

View File

@@ -218,6 +218,10 @@ func TestRegistryPatternLayers(t *testing.T) {
{item, listHost, "", item, item},
{item, listHost, recHost, item, item},
{" ", "", recHost, recHost, recHost},
// An override that is not a path is ignored (CR-01).
{"javascript://%0aalert(1)%0a//x", "", recHost, recHost, recHost},
{"//evil.test/:slug", "", "", "/posts-default/:slug", "/post-default/:slug"},
{`/\evil.test/:slug`, listHost, "", listHost, "/post-default/:slug"},
} {
name := fmt.Sprintf("item=%q list=%q record=%q", tc.item, tc.listHost, tc.recHost)
t.Run(name, func(t *testing.T) {
@@ -278,6 +282,28 @@ func TestRegistryPatternLayers(t *testing.T) {
})
}
// TestValidItemPattern pins the item override rule: a path starting with a
// single "/", without surrounding whitespace or control characters.
func TestValidItemPattern(t *testing.T) {
for p, want := range map[string]bool{
"/blog/:slug": true,
"/": true,
"": false,
"blog/:slug": false,
"//evil.test/:slug": false,
`/\evil.test`: false,
"/\t/evil.test": false,
" /blog/:slug": false,
"/blog/:slug ": false,
"javascript://%0aalert(1)%0a//x": false,
"https://example.test/:slug": false,
} {
if got := signpost.ValidItemPattern(p); got != want {
t.Errorf("ValidItemPattern(%q) = %v, want %v", p, got, want)
}
}
}
// TestReferenceOptions pins the one admin binding: a reference tree is
// flattened depth first with Depth the nesting level, a type without
// References or an unknown type answers Applies false with no options, and a