Files
kandev/apps/backend/internal/gateway/websocket/proxy_rewriter_css.go
T
2026-08-15 23:46:38 +01:00

443 lines
12 KiB
Go

package websocket
import (
"strings"
)
// This file holds the CSS URL rewriter. It lives apart from
// proxy_rewriter.go so the latter stays under the revive file-length limit.
// rewriteCSSURLsAt rewrites url(/...) and @import "/..." occurrences inside a
// standalone CSS document, resolving relative references against the given
// base directory (the stylesheet file's public directory).
func rewriteCSSURLsAt(body []byte, prefix, capability, basePath string) []byte {
return []byte(rewriteCSSFragment(string(body), prefix, capability, basePath))
}
// cssScanner rewrites CSS URL references inside an arbitrary string (either
// a full CSS file or the contents of an inline style attribute) with a small
// tokenizer so only REAL url() tokens and @import strings are rewritten.
// String literals and comments are copied untouched — a CSS string like
// content:'url(/x)' is data, not a URL reference — and url(var(--x)) is left
// alone because var() cannot be a URL token. Unquoted url() tokens run to
// whitespace or ')', which keeps data: URLs intact (their ';' and ',' are
// URL content, e.g. url(data:image/png;base64,AAA)). Function names and
// identifiers may use CSS escapes (\75rl( == url(), matching browser
// preprocessing). Path-absolute references get the proxy prefix plus the
// capability; relative references keep their resolution but still get the
// capability appended so cookie-less CSS loads stay authorized. Scheme-bearing
// (incl. data:), network-relative, and fragment-only references are left
// untouched by rewriteURLReference.
type cssScanner struct {
out strings.Builder
css string
prefix string
capability string
basePath string
i int
}
func rewriteCSSFragment(css, prefix, capability, basePath string) string {
s := &cssScanner{css: css, prefix: prefix, capability: capability, basePath: basePath}
for s.i < len(css) {
if s.consumeComment() || s.consumeString() || s.consumeURL() || s.consumeImport() {
continue
}
s.out.WriteByte(s.css[s.i])
s.i++
}
return s.out.String()
}
// consumeComment copies a /* ... */ comment (or the remainder when
// unterminated) and reports whether it consumed anything.
func (s *cssScanner) consumeComment() bool {
css := s.css
if s.i+1 >= len(css) || css[s.i] != '/' || css[s.i+1] != '*' {
return false
}
if end := strings.Index(css[s.i+2:], "*/"); end >= 0 {
s.out.WriteString(css[s.i : s.i+2+end+2])
s.i += 2 + end + 2
} else {
s.out.WriteString(css[s.i:])
s.i = len(css)
}
return true
}
// consumeString copies a quoted CSS string (escape-aware) and reports whether
// it consumed anything.
func (s *cssScanner) consumeString() bool {
css := s.css
c := css[s.i]
if c != '\'' && c != '"' {
return false
}
j := s.i + 1
for j < len(css) {
if css[j] == '\\' && j+1 < len(css) {
j += 2
continue
}
if css[j] == c {
break
}
j++
}
if j >= len(css) {
s.out.WriteString(css[s.i:])
s.i = len(css)
return true
}
s.out.WriteString(css[s.i : j+1])
s.i = j + 1
return true
}
// consumeURL rewrites a url() function token (raw or escape-written function
// name) and reports whether it consumed anything at s.i.
func (s *cssScanner) consumeURL() bool {
css := s.css
c := css[s.i]
if c != 'u' && c != 'U' && c != '\\' {
return false
}
if s.i > 0 && isCSSIdentChar(css[s.i-1]) {
return false // noturl(/x) is not a url() token
}
open := cssFuncOpen(css, s.i, "url")
if open < 0 {
return false
}
// The original function-name bytes are preserved in the output.
start := s.i
j := open + 1
for j < len(css) && isCSSSpace(css[j]) {
j++
}
spacing := css[open+1 : j]
if j < len(css) && (css[j] == '\'' || css[j] == '"') {
s.quotedURL(start, open, j, spacing)
return true
}
s.unquotedURL(start, open, j, spacing)
return true
}
// quotedURL rewrites url('...') with an escape-aware quoted token.
func (s *cssScanner) quotedURL(start, open, j int, spacing string) {
css := s.css
q := css[j]
k := j + 1
for k < len(css) {
if css[k] == '\\' && k+1 < len(css) {
k += 2
continue
}
if css[k] == q {
break
}
k++
}
if k >= len(css) {
s.out.WriteString(css[start:])
s.i = len(css)
return
}
l := k + 1
for l < len(css) && isCSSSpace(css[l]) {
l++
}
if l < len(css) && css[l] == ')' {
s.out.WriteString(css[start:open+1] + spacing + string(q) + rewriteCSSURLToken(css[j+1:k], s.prefix, s.capability, s.basePath) + string(q) + css[k+1:l] + ")")
s.i = l + 1
return
}
s.out.WriteString(css[start : k+1])
s.i = k + 1
}
// unquotedURL rewrites url(token) with an unquoted token (runs to
// whitespace or ')', but a whitespace after a hex escape is the escape's
// terminator and part of the token; var() is not a URL token and is
// preserved).
func (s *cssScanner) unquotedURL(start, open, j int, spacing string) {
css := s.css
k := j
for k < len(css) && css[k] != ')' {
if css[k] == '\\' {
_, adv := cssEscapeDecode(css, k)
k += adv
continue
}
if isCSSSpace(css[k]) {
break
}
k++
}
l := k
for l < len(css) && isCSSSpace(css[l]) {
l++
}
if l < len(css) && css[l] == ')' {
tok := css[j:k]
decoded := cssDecodeToken(tok)
if !strings.HasPrefix(strings.ToLower(decoded), "var(") {
s.out.WriteString(css[start:open+1] + spacing + rewriteCSSURLToken(tok, s.prefix, s.capability, s.basePath) + css[k:l] + ")")
s.i = l + 1
return
}
s.out.WriteString(css[start : l+1])
s.i = l + 1
return
}
s.out.WriteString(css[start:k])
s.i = k
}
// consumeImport rewrites @import "…"; strings (comments are CSS whitespace
// and are accepted between @import and the string) and reports whether it
// consumed anything.
func (s *cssScanner) consumeImport() bool {
css := s.css
if s.i+7 > len(css) || css[s.i] != '@' || !strings.EqualFold(css[s.i:s.i+7], "@import") {
return false
}
j := skipCSSSpaceAndComments(css, s.i+7)
if j < len(css) && (css[j] == '\'' || css[j] == '"') {
q := css[j]
k := j + 1
for k < len(css) {
if css[k] == '\\' && k+1 < len(css) {
k += 2
continue
}
if css[k] == q {
break
}
k++
}
if k >= len(css) {
s.out.WriteString(css[s.i:])
s.i = len(css)
return true
}
s.out.WriteString(css[s.i:j]) // "@import " + whitespace/comments
s.out.WriteString(string(q) + rewriteCSSURLToken(css[j+1:k], s.prefix, s.capability, s.basePath) + string(q))
s.i = k + 1
return true
}
// @import url(...) — the url() case rewrites it.
return false
}
// cssDecodeToken decodes every CSS escape in a url() token value: the
// browser preprocesses escapes before resolving the URL, so classification
// must see the decoded form (url(\2f asset.css) is url(/asset.css)).
func cssDecodeToken(tok string) string {
var b strings.Builder
for i := 0; i < len(tok); {
if tok[i] == '\\' {
dec, adv := cssEscapeDecode(tok, i)
b.WriteString(dec)
i += adv
continue
}
b.WriteByte(tok[i])
i++
}
return b.String()
}
// cssEscapeToken re-escapes CSS-significant characters in a rewritten URL so
// the emitted token survives CSS tokenization: whitespace, quotes, parens,
// backslashes, and control characters become \XX hex escapes (the trailing
// space is the optional hex-escape terminator and is consumed by the parser).
// Plain proxy URLs need no escaping; this covers decoded-token rewrites whose
// decoded value contained CSS-significant characters.
func cssEscapeToken(tok string) string {
const hex = "0123456789ABCDEF"
var b strings.Builder
for i := 0; i < len(tok); i++ {
c := tok[i]
if c == '\\' || c == '\'' || c == '"' || c == '(' || c == ')' || isCSSSpace(c) || c < 0x20 || c == 0x7F {
b.WriteByte('\\')
b.WriteByte(hex[c>>4])
b.WriteByte(hex[c&0xF])
b.WriteByte(' ')
continue
}
b.WriteByte(c)
}
return b.String()
}
// rewriteCSSURLToken classifies a url() token value (with CSS escapes
// decoded for classification) and returns the bytes to emit: the decoded
// rewritten URL when the reference changed, or the ORIGINAL raw token bytes
// when it did not (external/scheme/network-relative references keep their
// spelling).
func rewriteCSSURLToken(rawToken, prefix, capability, basePath string) string {
decoded := cssDecodeToken(rawToken)
rewritten := rewriteURLReferenceBase(decoded, prefix, capability, basePath)
if rewritten == decoded {
return rawToken
}
return cssEscapeToken(rewritten)
}
// isCSSSpace reports whether c is CSS whitespace (per the CSS syntax spec).
func isCSSSpace(c byte) bool {
switch c {
case ' ', '\t', '\n', '\r', '\f':
return true
}
return false
}
// isCSSIdentChar reports whether c can be part of a CSS identifier (per the
// CSS syntax spec's ident-token: ASCII letters, digits, '-', '_', and
// non-ASCII code points). Used to require a token boundary before `url(` so
// `noturl(/x)` is not mistaken for a url() token.
func isCSSIdentChar(c byte) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c >= 0x80
}
// isCSSNameStart reports whether c can start a CSS identifier: ASCII letters,
// '_', or a non-ASCII code point (digits and '-' are excluded per the CSS
// syntax spec; escapes are handled separately by cssIdentAt).
func isCSSNameStart(c byte) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '_' || c >= 0x80
}
func isCSSHexDigit(c byte) bool {
return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'
}
func cssHexVal(c byte) int {
switch {
case c >= '0' && c <= '9':
return int(c - '0')
case c >= 'a' && c <= 'f':
return int(c-'a') + 10
default:
return int(c-'A') + 10
}
}
// cssEscapeDecode decodes one CSS escape at css[i] (css[i] must be '\'):
// a hex escape (\75, up to 6 hex digits, optional whitespace terminator)
// yields its code point; an escaped non-newline character yields itself; an
// escaped newline is removed (it is not a character). Returns the decoded
// text and the number of bytes consumed.
func cssEscapeDecode(css string, i int) (string, int) {
if i+1 >= len(css) {
return "", 1
}
switch c := css[i+1]; {
case isCSSHexDigit(c):
return cssHexEscape(css, i)
case c == '\n' || c == '\r' || c == '\f':
return "", 2
default:
return string(c), 2
}
}
// cssHexEscape decodes the \XXXXXX form: at most 6 hex digits with an
// optional single whitespace terminator; invalid code points become U+FFFD.
func cssHexEscape(css string, i int) (string, int) {
j := i + 1
v := 0
for j < len(css) && j-i < 7 && isCSSHexDigit(css[j]) {
v = v*16 + cssHexVal(css[j])
j++
}
if j < len(css) && isCSSSpace(css[j]) {
j++ // consume the optional whitespace terminator
}
if v == 0 || v > 0x10FFFF || v >= 0xD800 && v <= 0xDFFF {
return "\uFFFD", j - i
}
return string(rune(v)), j - i
}
// cssIdentAt decodes the identifier (raw or escape-containing) starting at
// css[i], returning the decoded name, the byte length consumed, and whether
// an identifier was actually found. \75rl( and u\72l( both decode to "url",
// matching how the CSS tokenizer preprocesses escapes before recognizing
// function names.
func cssIdentAt(css string, i int) (string, int, bool) {
if i >= len(css) {
return "", 0, false
}
var b strings.Builder
j := i
first := true
loop:
for j < len(css) {
c := css[j]
switch {
case c == '\\':
dec, adv := cssEscapeDecode(css, j)
if dec == "" {
break loop
}
if !first && !isCSSIdentChar(dec[0]) {
break loop
}
b.WriteString(dec)
j += adv
case first && isCSSNameStart(c):
b.WriteByte(c)
j++
case !first && isCSSIdentChar(c):
b.WriteByte(c)
j++
default:
break loop
}
first = false
}
if j == i {
return "", 0, false
}
return b.String(), j - i, true
}
// cssFuncOpen returns the index of the '(' that terminates a CSS function
// token with the given decoded name starting at css[i] (which may use CSS
// escapes, e.g. \75rl( decodes to "url"), or -1 when css[i] does not start
// such a function. The caller is responsible for the identifier boundary
// check on the raw previous byte.
func cssFuncOpen(css string, i int, name string) int {
dec, adv, ok := cssIdentAt(css, i)
if !ok || !strings.EqualFold(dec, name) {
return -1
}
if i+adv < len(css) && css[i+adv] == '(' {
return i + adv
}
return -1
}
// skipCSSSpaceAndComments advances j past CSS whitespace and comments (both
// are whitespace per the CSS syntax spec), returning the new position.
func skipCSSSpaceAndComments(css string, j int) int {
for j < len(css) {
if isCSSSpace(css[j]) {
j++
continue
}
if css[j] == '/' && j+1 < len(css) && css[j+1] == '*' {
if end := strings.Index(css[j+2:], "*/"); end >= 0 {
j += 2 + end + 2
continue
}
}
break
}
return j
}