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:
@@ -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{}
|
||||
)
|
||||
|
||||
39
controllers/definitions_test.go
Normal file
39
controllers/definitions_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user