Problem
The proxy variant of the tracking script (dist/latest/custom/proxy.js, built from src/default.js with the {{nginxProxyHost}} placeholder) ends with }(window,{},"<base url>"), where the base URL is filled in by the web server at request time from the hostname and path query parameters. The value lands directly inside a double-quoted JavaScript string literal with no escaping, so any substituted value containing a quote or backslash breaks out of the literal and executes as script on the domain serving the file — a customer-controlled proxy domain.
On the old nginx custom-domains server this is not reachable, because nginx's $arg_hostname and $arg_path return the raw, still percent-encoded query value: a request for ?hostname=%22%2balert(1)%2b%22 emits the literal text %22%2balert(1)%2b%22 and the string literal stays intact.
The Caddy custom-domains setup evaluates the same placeholders through Caddy's templates directive, which uses Go's text/template and applies no contextual escaping. The natural query lookup there, {{.Req.URL.Query.Get "hostname"}}, percent-decodes before substituting. Executing that template against a request for /proxy.js?hostname=%22%2balert(document.domain)%2b%22&path=%2Fsimple produces }(window,{},""+alert(document.domain)+"/simple"), giving arbitrary script execution in the origin of whichever domain serves the file.
The same unescaped-substitution shape also applies to {{.Req.Host}} in the non-proxy custom-domain scripts (latest.js, events.js, light.js, v<n>/app.js), where the value comes from the request Host header. Go's net/http host-header validation is permissive enough to admit a quote character, so that path deserves checking too even though it matches the pre-existing nginx behaviour rather than regressing it.
Suggested changes
- Escape every server-substituted value before it reaches the JavaScript string literal, for example by wrapping the Caddy template expressions in Go's built-in
js escaper ({{js (.Req.URL.Query.Get "hostname")}}), which preserves percent-decoding while neutralising quotes and backslashes.
- Decide deliberately whether the Caddy setup should keep nginx's raw, still-encoded semantics for
hostname and path, or move to decoded values, and record that decision next to the substitution table so future edits do not silently change it.
- Review
{{.Req.Host}} substitution in the non-proxy custom-domain scripts against a request carrying a quote in the Host header, and escape it the same way if the server accepts such a header.
- Add a regression test that runs a hostile
hostname/path query value through the actual template engine and asserts the resulting script still parses as a single string literal, rather than only asserting the placeholder text.
- Consider having
src/default.js read the injected base URL through a form that cannot break out of surrounding syntax, such as a JSON-encoded or separately-declared value, so correctness no longer depends on server-side escaping.
Problem
The proxy variant of the tracking script (
dist/latest/custom/proxy.js, built fromsrc/default.jswith the{{nginxProxyHost}}placeholder) ends with}(window,{},"<base url>"), where the base URL is filled in by the web server at request time from thehostnameandpathquery parameters. The value lands directly inside a double-quoted JavaScript string literal with no escaping, so any substituted value containing a quote or backslash breaks out of the literal and executes as script on the domain serving the file — a customer-controlled proxy domain.On the old nginx custom-domains server this is not reachable, because nginx's
$arg_hostnameand$arg_pathreturn the raw, still percent-encoded query value: a request for?hostname=%22%2balert(1)%2b%22emits the literal text%22%2balert(1)%2b%22and the string literal stays intact.The Caddy custom-domains setup evaluates the same placeholders through Caddy's
templatesdirective, which uses Go'stext/templateand applies no contextual escaping. The natural query lookup there,{{.Req.URL.Query.Get "hostname"}}, percent-decodes before substituting. Executing that template against a request for/proxy.js?hostname=%22%2balert(document.domain)%2b%22&path=%2Fsimpleproduces}(window,{},""+alert(document.domain)+"/simple"), giving arbitrary script execution in the origin of whichever domain serves the file.The same unescaped-substitution shape also applies to
{{.Req.Host}}in the non-proxy custom-domain scripts (latest.js,events.js,light.js,v<n>/app.js), where the value comes from the request Host header. Go'snet/httphost-header validation is permissive enough to admit a quote character, so that path deserves checking too even though it matches the pre-existing nginx behaviour rather than regressing it.Suggested changes
jsescaper ({{js (.Req.URL.Query.Get "hostname")}}), which preserves percent-decoding while neutralising quotes and backslashes.hostnameandpath, or move to decoded values, and record that decision next to the substitution table so future edits do not silently change it.{{.Req.Host}}substitution in the non-proxy custom-domain scripts against a request carrying a quote in the Host header, and escape it the same way if the server accepts such a header.hostname/pathquery value through the actual template engine and asserts the resulting script still parses as a single string literal, rather than only asserting the placeholder text.src/default.jsread the injected base URL through a form that cannot break out of surrounding syntax, such as a JSON-encoded or separately-declared value, so correctness no longer depends on server-side escaping.