Files
sm-sitemap-plugin/models/settings.go
Jakub Zych 1bb89ce006 fix(17): T-17-15 refuse an unsafe frontend_url on save and import
- Settings.BeforeValidate answers a cabana validation error on
  frontend_url, so the settings save is a 422 and the row is unchanged
- sitemap:import-winter stops on a frontend_url that
  models.AllowedFrontendURL refuses, before the dry-run branch and before
  any database use; the value is printed quoted
- errors.frontend_url phrase in en and pl
- TestSettingsBeforeValidate, TestWinterSettingsFrontendURL and the save
  and import refusals in the Postgres smokes
2026-10-13 00:59:24 +02:00

145 lines
4.9 KiB
Go

package models
import (
"net/url"
"strings"
"unicode"
"unicode/utf8"
"git.golem15.com/golem15/summercms/modules/cabana"
"gorm.io/gorm"
)
// errFrontendURL is the phrase key of the frontend_url refusal.
const errFrontendURL = "golem15.sitemap::lang.errors.frontend_url"
// The settings keys of the five priority tiers, in the order PHP checked
// them. A tier rule names one of them.
const (
PriorityHomepage = "priority_homepage"
PriorityServices = "priority_services"
PriorityBlog = "priority_blog"
PriorityCategories = "priority_categories"
PriorityLegal = "priority_legal"
)
// PriorityDefaults are the PHP defaults of the priority settings.
var PriorityDefaults = map[string]string{
PriorityHomepage: "1.0",
PriorityServices: "0.8",
PriorityBlog: "0.6",
PriorityCategories: "0.4",
PriorityLegal: "0.2",
}
// Settings is the sitemap settings singleton (row id 1) on
// golem15_sitemap_settings, the PHP Golem15\Sitemap\Models\Settings that
// WinterCMS kept in system_settings under golem15_sitemap_settings.
// FrontendURL replaces the backend URL in the generated XML;
// FrontendDefaultLocale is the locale whose URLs carry no prefix;
// UpdatesManifestPath names a JSON file of URL path to last-modified date;
// EnablePriorityTiers turns the tier priorities on, and the priority fields
// hold each tier's value as text. A null priority reads as its PHP default.
// The PHP settings form validated nothing; the port checks FrontendURL in
// BeforeValidate.
type Settings struct {
ID uint `gorm:"column:id;primaryKey"`
FrontendURL *string `gorm:"column:frontend_url"`
FrontendDefaultLocale *string `gorm:"column:frontend_default_locale"`
UpdatesManifestPath *string `gorm:"column:updates_manifest_path"`
EnablePriorityTiers bool `gorm:"column:enable_priority_tiers"`
PriorityHomepage *string `gorm:"column:priority_homepage"`
PriorityServices *string `gorm:"column:priority_services"`
PriorityBlog *string `gorm:"column:priority_blog"`
PriorityCategories *string `gorm:"column:priority_categories"`
PriorityLegal *string `gorm:"column:priority_legal"`
}
func (Settings) TableName() string { return "golem15_sitemap_settings" }
// Fillable lists only the settings columns.
func (Settings) Fillable() []string {
return []string{
"frontend_url", "frontend_default_locale", "updates_manifest_path",
"enable_priority_tiers", PriorityHomepage, PriorityServices,
PriorityBlog, PriorityCategories, PriorityLegal,
}
}
// Rules is empty: the PHP settings form validated nothing. The one check of
// the port, on frontend_url, is in BeforeValidate.
func (Settings) Rules() map[string]string { return map[string]string{} }
// BeforeValidate is the lagoon.HasBeforeValidate hook cabana calls on a
// settings save, after the fill and before the row is written: a
// frontend_url that AllowedFrontendURL refuses is a validation error on
// frontend_url, so the save answers 422 and the row is left as it was.
func (s Settings) BeforeValidate(*gorm.DB) error {
if AllowedFrontendURL(Text(s.FrontendURL)) {
return nil
}
return &cabana.ValidationError{Details: map[string]any{"frontend_url": []string{errFrontendURL}}}
}
// AllowedFrontendURL reports whether u may be the frontend_url setting: the
// empty string, or an absolute http or https URL with a host and without
// whitespace, control characters, a double quote, <, > or &. The PHP plugin
// stored any text. The value is written into every loc, every hreflang link
// and the stylesheet instruction of the sitemap, and sitemap.xsl renders
// each loc as a link on the public origin, so a value outside this form
// (a javascript: URL, or text that closes the attribute it is written into)
// would reach the readers of /sitemap.xml (T-17-15).
func AllowedFrontendURL(u string) bool {
if u == "" {
return true
}
if !utf8.ValidString(u) {
return false
}
if strings.ContainsFunc(u, func(r rune) bool {
return unicode.IsControl(r) || unicode.IsSpace(r) || r == '"' || r == '<' || r == '>' || r == '&'
}) {
return false
}
parsed, err := url.Parse(u)
if err != nil {
return false
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return false
}
return parsed.Opaque == "" && parsed.Hostname() != ""
}
// DefaultSettings is the settings PHP read when nothing was saved: tiers on
// with the default priorities.
func DefaultSettings() Settings {
return Settings{ID: 1, EnablePriorityTiers: true}
}
// Priority returns the value of a priority setting: the stored text, or the
// PHP default when it is null.
func (s Settings) Priority(key string) string {
var p *string
switch key {
case PriorityHomepage:
p = s.PriorityHomepage
case PriorityServices:
p = s.PriorityServices
case PriorityBlog:
p = s.PriorityBlog
case PriorityCategories:
p = s.PriorityCategories
case PriorityLegal:
p = s.PriorityLegal
}
if p == nil {
return PriorityDefaults[key]
}
return *p
}
func init() {
Register(Settings{})
}