fix(17): T-17-15 apply the frontend_url rewrite only for a safe http(s) URL

- models.AllowedFrontendURL: empty, or an absolute http(s) URL with a host
  and without whitespace, control characters, a double quote, <, > or &
- classes.Rewrite leaves the document unchanged for a refused value,
  inserts through EscapeText/EscapeAttr and keeps a loc or href that would
  not stay an http(s) URL
- TestRewriteUnsafeFrontendURL, TestAllowedFrontendURL and a stored-row
  check in TestSitemapImportSmoke
This commit is contained in:
Jakub Zych
2026-10-13 00:57:02 +02:00
parent cf5c40e442
commit b9804ddf17
6 changed files with 241 additions and 20 deletions

View File

@@ -3,6 +3,8 @@ package classes
import (
"regexp"
"strings"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
)
// The PHP routes.php patterns of the frontend rewrite.
@@ -16,30 +18,48 @@ var (
// element, inside every xhtml:link href and in the exact stylesheet href.
// Both URLs are right-trimmed of slashes first. An empty frontendURL (or
// "0", which PHP reads as false) leaves xml unchanged.
//
// PHP inserted the stored text as it was. Three rules keep that text out of
// the document unless it is a plain http(s) URL (T-17-15):
//
// - a frontendURL that models.AllowedFrontendURL refuses leaves xml
// unchanged, whatever is stored in the settings row;
// - the frontend URL is inserted through EscapeText in loc and through
// EscapeAttr in the hrefs. Both are the identity for every accepted
// value, which keeps the PHP bytes;
// - a loc or xhtml:link href whose rewritten value does not start with
// http:// or https:// keeps its value. The generator applies the same
// filter before the rewrite.
func Rewrite(xml, backendURL, frontendURL string) string {
if frontendURL == "" || frontendURL == "0" {
return xml
}
if !models.AllowedFrontendURL(frontendURL) {
return xml
}
frontend := strings.TrimRight(frontendURL, "/")
backend := strings.TrimRight(backendURL, "/")
replace := func(s string) string {
if backend == "" {
return s
}
return strings.ReplaceAll(s, backend, frontend)
if backend == "" {
return xml
}
frontendText, frontendAttr := EscapeText(frontend), EscapeAttr(frontend)
xml = locElement.ReplaceAllStringFunc(xml, func(m string) string {
inner := m[len("<loc>") : len(m)-len("</loc>")]
return "<loc>" + replace(inner) + "</loc>"
rewritten := strings.ReplaceAll(inner, backend, frontendText)
if !httpURL(rewritten) {
return m
}
return "<loc>" + rewritten + "</loc>"
})
xml = xhtmlLinkRef.ReplaceAllStringFunc(xml, func(m string) string {
sub := xhtmlLinkRef.FindStringSubmatch(m)
return sub[1] + replace(sub[2]) + sub[3]
rewritten := strings.ReplaceAll(sub[2], backend, frontendAttr)
if !httpURL(rewritten) {
return m
}
return sub[1] + rewritten + sub[3]
})
if backend != "" {
xml = strings.ReplaceAll(xml, `href="`+backend+`/sitemap.xsl"`, `href="`+frontend+`/sitemap.xsl"`)
}
return xml
return strings.ReplaceAll(xml, `href="`+backend+`/sitemap.xsl"`, `href="`+frontendAttr+`/sitemap.xsl"`)
}
// BackendURL is PHP rtrim(url('/'), '/') for a request whose root URL is

View File

@@ -1,22 +1,31 @@
package classes_test
import (
"context"
"strings"
"testing"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
)
// rewriteBackend and rewriteDoc are the backend URL and the document of the
// rewrite tests: a stylesheet instruction, a loc, hreflang links and the
// backend URL in places the rewrite leaves alone.
const rewriteBackend = "https://api.ex.pl"
var rewriteDoc = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
`<?xml-stylesheet type="text/xsl" href="https://api.ex.pl/sitemap.xsl"?>` + "\n" +
`<urlset><url><loc>https://api.ex.pl/a</loc><lastmod>2026</lastmod><changefreq>https://api.ex.pl</changefreq><priority>1</priority>` +
`<xhtml:link rel="alternate" hreflang="pl" href="https://api.ex.pl/a"/><xhtml:link rel="alternate" hreflang="en" href="https://api.ex.pl/en/a"/>` +
`<extra>https://api.ex.pl/x</extra></url><url><loc>https://other.pl/https://api.ex.pl</loc><changefreq>c</changefreq><priority>p</priority></url></urlset>` + "\n"
// TestRewrite pins the PHP routes.php frontend_url rewrite: the backend URL
// is replaced inside loc, inside xhtml:link href and in the exact stylesheet
// href, nowhere else; slashes are trimmed on both; an empty or "0" frontend
// URL and equal URLs leave the document unchanged.
func TestRewrite(t *testing.T) {
const backend = "https://api.ex.pl"
doc := "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
`<?xml-stylesheet type="text/xsl" href="https://api.ex.pl/sitemap.xsl"?>` + "\n" +
`<urlset><url><loc>https://api.ex.pl/a</loc><lastmod>2026</lastmod><changefreq>https://api.ex.pl</changefreq><priority>1</priority>` +
`<xhtml:link rel="alternate" hreflang="pl" href="https://api.ex.pl/a"/><xhtml:link rel="alternate" hreflang="en" href="https://api.ex.pl/en/a"/>` +
`<extra>https://api.ex.pl/x</extra></url><url><loc>https://other.pl/https://api.ex.pl</loc><changefreq>c</changefreq><priority>p</priority></url></urlset>` + "\n"
const backend = rewriteBackend
doc := rewriteDoc
want := "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
`<?xml-stylesheet type="text/xsl" href="https://www.ex.pl/sitemap.xsl"?>` + "\n" +
`<urlset><url><loc>https://www.ex.pl/a</loc><lastmod>2026</lastmod><changefreq>https://api.ex.pl</changefreq><priority>1</priority>` +
@@ -50,3 +59,73 @@ func TestRewrite(t *testing.T) {
t.Fatalf("other stylesheet = %q", got)
}
}
// The two frontend_url values of the T-17-15 reproduction: a script URL the
// stylesheet would render as a link, and a value that closes the attribute
// and the element it is written into.
var frontendAttacks = []string{
"javascript:alert(document.domain)//",
`https://x"/><y a="`,
}
// TestRewriteUnsafeFrontendURL pins the T-17-15 output rule: a frontend URL
// that models.AllowedFrontendURL refuses leaves the document byte for byte
// as it was, so no part of it reaches loc, an hreflang href or the
// stylesheet instruction; an accepted one still rewrites; and a loc or href
// that the rewrite would turn into something other than an http(s) URL
// keeps its value.
func TestRewriteUnsafeFrontendURL(t *testing.T) {
refused := append([]string{
"https://www.ex.pl/?a=1&b=2",
"https://www.ex.pl <x>",
" https://www.ex.pl",
"https://www.ex.pl\n",
"//www.ex.pl",
"www.ex.pl",
"https:///x",
"data:text/html,x",
}, frontendAttacks...)
for _, frontend := range refused {
if got := classes.Rewrite(rewriteDoc, rewriteBackend, frontend); got != rewriteDoc {
t.Errorf("frontend %q changed the document:\n%s", frontend, got)
}
}
// The reproduction through the generator.
const root = "https://api.example.test"
xml, err := (&classes.Generator{Root: root}).Generate(context.Background(), urlItems("/blog/x"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(xml, `href="https://api.example.test/sitemap.xsl"`) {
t.Fatalf("the generated document lacks the stylesheet instruction:\n%s", xml)
}
for _, frontend := range frontendAttacks {
got := classes.Rewrite(xml, classes.BackendURL(root), frontend)
if got != xml {
t.Errorf("frontend %q changed the generated sitemap:\n%s", frontend, got)
}
if !strings.Contains(got, "<loc>https://api.example.test/blog/x</loc>") {
t.Errorf("frontend %q: the loc is not on the backend URL:\n%s", frontend, got)
}
if lower := strings.ToLower(got); strings.Contains(lower, "javascript") || strings.Contains(lower, "<y a=") {
t.Errorf("frontend %q reached the sitemap:\n%s", frontend, got)
}
}
// A value the predicate accepts still rewrites all three places.
got := classes.Rewrite(xml, classes.BackendURL(root), "https://www.example.test")
if !strings.Contains(got, "<loc>https://www.example.test/blog/x</loc>") || !strings.Contains(got, `href="https://www.example.test/sitemap.xsl"`) || strings.Contains(got, "api.example.test") {
t.Errorf("a valid frontend URL did not rewrite the sitemap:\n%s", got)
}
if got := classes.Rewrite(xml, classes.BackendURL(root), "HTTPS://WWW.EXAMPLE.TEST"); !strings.Contains(got, "<loc>HTTPS://WWW.EXAMPLE.TEST/blog/x</loc>") {
t.Errorf("an upper-case frontend URL did not rewrite the loc:\n%s", got)
}
// The http(s) output filter after the rewrite: replacing "s://api.ex.pl"
// would write httphttps://www.ex.pl/a, so loc and href keep their values.
entry := `<url><loc>https://api.ex.pl/a</loc><xhtml:link rel="alternate" hreflang="pl" href="https://api.ex.pl/a"/></url>`
if got := classes.Rewrite(entry, "s://api.ex.pl", "https://www.ex.pl"); got != entry {
t.Errorf("a rewrite to a non-http(s) URL was written:\n%s", got)
}
}