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
This commit is contained in:
Jakub Zych
2026-10-13 00:59:24 +02:00
parent b9804ddf17
commit 1bb89ce006
9 changed files with 204 additions and 8 deletions

View File

@@ -37,7 +37,8 @@ var droppedTypes = map[string]bool{
// are checked. PageURLs turns a static or CMS page item into a url item:
// its key is the item's reference (the PHP page file name, for example
// blog), its value the page's URL (/blog). Force replaces a definition that
// has items; DryRun reports without writing.
// has items; DryRun reports without writing. A frontend_url that
// models.AllowedFrontendURL refuses stops the import and nothing is written.
type WinterOptions struct {
DefinitionsFile string
SettingsFile string
@@ -71,7 +72,8 @@ type WinterReport struct {
// (signpost.ValidItemPattern) is cleared, and every dropped, remapped or
// cleared item is printed. A definition that already has items is refused
// without --force. Settings keys missing from the export take the PHP
// defaults.
// defaults. A frontend_url that models.AllowedFrontendURL refuses stops the
// import and nothing is written.
func ImportWinter(app *backpack.App) bonfire.Command {
return bonfire.Command{
Name: "sitemap:import-winter",
@@ -418,7 +420,8 @@ func describeItem(label string, item models.DefinitionItem) string {
}
// readWinterSettings reads the exported settings object onto the PHP
// defaults.
// defaults. A frontend_url that models.AllowedFrontendURL refuses is an
// error; the value is quoted so its control characters are not printed raw.
func readWinterSettings(file string, report *WinterReport) (models.Settings, error) {
raw, err := os.ReadFile(file)
if err != nil {
@@ -460,5 +463,8 @@ func readWinterSettings(file string, report *WinterReport) (models.Settings, err
}
report.Lines = append(report.Lines, fmt.Sprintf("setting '%s' ignored (not a sitemap setting of this port)", key))
}
if url := models.Text(s.FrontendURL); !models.AllowedFrontendURL(url) {
return models.Settings{}, fmt.Errorf("%s: setting frontend_url %q must be empty or an absolute http:// or https:// URL without whitespace, control characters, quotes or the characters <, > and &", file, url)
}
return s, nil
}

View File

@@ -0,0 +1,92 @@
package console
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/bonfire"
)
// TestWinterSettingsFrontendURL pins the import check of T-17-15: a settings
// export whose frontend_url models.AllowedFrontendURL refuses stops
// sitemap:import-winter with an error naming the setting, with and without
// --dry-run and before the database is used (the test passes none). An
// accepted, empty, null or missing frontend_url is read as before.
func TestWinterSettingsFrontendURL(t *testing.T) {
dir := t.TempDir()
n := 0
settingsFile := func(fields map[string]any) string {
t.Helper()
// json.Marshal encodes the quotes of the attack value.
raw, err := json.Marshal(fields)
if err != nil {
t.Fatal(err)
}
n++
path := filepath.Join(dir, "settings-"+string(rune('a'+n))+".json")
if err := os.WriteFile(path, raw, 0o644); err != nil {
t.Fatal(err)
}
return path
}
for _, value := range []string{
"javascript:alert(document.domain)//",
`https://x"/><y a="`,
"0",
" https://www.example.test",
} {
file := settingsFile(map[string]any{"frontend_url": value, "enable_priority_tiers": "1"})
var report WinterReport
if _, err := readWinterSettings(file, &report); err == nil || !strings.Contains(err.Error(), "setting frontend_url") {
t.Errorf("readWinterSettings(%q) = %v, want an error naming setting frontend_url", value, err)
}
for _, dry := range []bool{false, true} {
var buf bytes.Buffer
out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf)
report, err := ImportWinterFiles(context.Background(), nil, nil, WinterOptions{SettingsFile: file, DryRun: dry}, out)
if err == nil || !strings.Contains(err.Error(), "setting frontend_url") {
t.Errorf("ImportWinterFiles(%q, dry run %v) = %v, want an error naming setting frontend_url", value, dry, err)
}
if report.Settings {
t.Errorf("ImportWinterFiles(%q, dry run %v) reports the settings as copied", value, dry)
}
if strings.Contains(buf.String(), "would be copied") || strings.Contains(buf.String(), "Settings copied") {
t.Errorf("ImportWinterFiles(%q, dry run %v) printed a copy line:\n%s", value, dry, buf.String())
}
}
}
// The error quotes the value, so a control character is not printed raw.
var report WinterReport
_, err := readWinterSettings(settingsFile(map[string]any{"frontend_url": "https://www.example.test\x1b[2J"}), &report)
if err == nil || strings.Contains(err.Error(), "\x1b") || !strings.Contains(err.Error(), `\x1b`) {
t.Errorf("a control character in frontend_url: %q", err)
}
for name, tc := range map[string]struct {
fields map[string]any
want string
}{
"valid": {map[string]any{"frontend_url": "https://www.example.test/"}, "https://www.example.test/"},
"empty": {map[string]any{"frontend_url": ""}, ""},
"null": {map[string]any{"frontend_url": nil}, ""},
"missing": {map[string]any{"enable_priority_tiers": "1"}, ""},
} {
var report WinterReport
s, err := readWinterSettings(settingsFile(tc.fields), &report)
if err != nil {
t.Errorf("%s: readWinterSettings = %v", name, err)
continue
}
if got := models.Text(s.FrontendURL); got != tc.want {
t.Errorf("%s: frontend_url = %q, want %q", name, got, tc.want)
}
}
}