fix(17): CR-01 refuse non-http sitemap URLs on save, import and output

A url item stored as javascript://%0aalert(1)%0a//x passed Url::to and
reached <loc>, which sitemap.xsl renders as a link on the site origin.

- The definitions form refuses, per row, a url that is not a path or an
  http(s) URL (classes.AllowedItemURL) and a urlPattern that is not a
  path (signpost.ValidItemPattern), keyed data.<i>.url / .urlPattern.
- Generator.addItem drops a loc or alternate that is not http(s).
- sitemap:import-winter drops such url items and clears such patterns
  with a report line; sitemap:import refuses such an entry.
This commit is contained in:
2026-10-12 21:28:28 +02:00
parent 4e55939a1f
commit c42bc265c8
12 changed files with 257 additions and 11 deletions

View File

@@ -4,9 +4,12 @@ package controllers
import (
"context"
"errors"
"fmt"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/signpost"
"gorm.io/gorm"
@@ -23,6 +26,12 @@ const (
// typeURLLabel is the label of the built-in url item type.
const typeURLLabel = "golem15.sitemap::lang.item.url"
// The phrase keys of the item row refusals.
const (
errURLScheme = "golem15.sitemap::lang.errors.url_scheme"
errURLPattern = "golem15.sitemap::lang.errors.url_pattern"
)
// definitionsAdminController edits the one sitemap definition of the
// application (theme default): a singleton form whose items are a repeater
// with a type dropdown (url plus the registry types) and a reference
@@ -96,6 +105,41 @@ func (c definitionsAdminController) DependentOptions(ctx context.Context, _ stri
return signpost.ReferenceOptions(ctx, c.registry(), typ)
}
// FormBeforeCreate checks the item rows (see checkItems).
func (c definitionsAdminController) FormBeforeCreate(_ context.Context, model any) error {
return c.checkItems(model)
}
// FormBeforeUpdate checks the item rows (see checkItems).
func (c definitionsAdminController) FormBeforeUpdate(_ context.Context, model any) error {
return c.checkItems(model)
}
// checkItems refuses, per row, a url outside classes.AllowedItemURL
// (data.<i>.url) and a URL pattern that is not a path
// (signpost.ValidItemPattern, data.<i>.urlPattern). The PHP plugin checked
// neither; a javascript: URL would reach <loc>, which sitemap.xsl renders as
// a link.
func (definitionsAdminController) checkItems(model any) error {
def, ok := model.(*models.Definition)
if !ok || def == nil {
return nil
}
details := map[string]any{}
for i, item := range def.Items() {
if !classes.AllowedItemURL(models.Text(item.URL)) {
details[fmt.Sprintf("data.%d.url", i)] = []string{errURLScheme}
}
if p := models.Text(item.URLPattern); p != "" && !signpost.ValidItemPattern(p) {
details[fmt.Sprintf("data.%d.urlPattern", i)] = []string{errURLPattern}
}
}
if len(details) > 0 {
return &cabana.ValidationError{Details: details}
}
return nil
}
var (
_ pact.AdminController = definitionsAdminController{}
_ pact.AdminPermissioned = definitionsAdminController{}
@@ -103,4 +147,6 @@ var (
_ pact.AdminSingleton = definitionsAdminController{}
_ pact.DropdownOptionsProvider = definitionsAdminController{}
_ pact.DependentOptionsProvider = definitionsAdminController{}
_ pact.FormBeforeCreate = definitionsAdminController{}
_ pact.FormBeforeUpdate = definitionsAdminController{}
)

View File

@@ -0,0 +1,39 @@
package controllers
import (
"errors"
"testing"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
)
func ptr(s string) *string { return &s }
// TestCheckItems pins the CR-01 save check: per row, a url outside
// classes.AllowedItemURL and a URL pattern that is not a path are refused
// under data.<i>.url and data.<i>.urlPattern.
func TestCheckItems(t *testing.T) {
c := definitionsAdminController{}
def := &models.Definition{}
def.SetItems([]models.DefinitionItem{
{Type: models.ItemTypeURL, URL: ptr("/about")},
{Type: models.ItemTypeURL, URL: ptr("javascript://%0aalert(1)%0a//x")},
{Type: "acme-post", URLPattern: ptr("//evil.test/:slug")},
{Type: "acme-post", URLPattern: ptr("/blog/:slug")},
})
err := c.checkItems(def)
var verr *cabana.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("checkItems = %v, want a validation error", err)
}
if len(verr.Details) != 2 || verr.Details["data.1.url"] == nil || verr.Details["data.2.urlPattern"] == nil {
t.Fatalf("details = %v", verr.Details)
}
ok := &models.Definition{}
ok.SetItems([]models.DefinitionItem{{Type: models.ItemTypeURL, URL: ptr("https://example.test/")}})
if err := c.checkItems(ok); err != nil {
t.Fatalf("a valid definition was refused: %v", err)
}
}