mirror of
https://github.com/h44z/wg-portal.git
synced 2026-09-03 21:36:44 +00:00
NFC normalisation ran before control and format characters were stripped.
Removing a character can leave a base letter next to a combining mark the
earlier pass never saw as a pair, so a second call composes it:
input U+0041 U+0009 U+0300 ("A", tab, combining grave)
once -> U+0041 U+0300
twice -> U+00C0
Found by TestPropertySanitizeStringIdempotent. Category Cf characters
behave the same way.
Strip first, then normalise. Normalisation still precedes truncation
because composing changes the rune count. One side effect: invalid UTF-8
is now dropped by the strip loop instead of surviving as U+FFFD, so an
identifier containing such bytes sanitises differently than before.
Signed-off-by: clark-ja <37738506+clark-ja@users.noreply.github.com>
164 lines
4.4 KiB
Go
164 lines
4.4 KiB
Go
package domain
|
|
|
|
import (
|
|
"log/slog"
|
|
"net/mail"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"golang.org/x/text/unicode/norm"
|
|
)
|
|
|
|
// LogSanitizeChange applies sanitizeFn to raw, logs when the value changes, and writes
|
|
// the sanitized value to dest. Raw and sanitized values are intentionally omitted.
|
|
func LogSanitizeChange(
|
|
providerType string,
|
|
providerName string,
|
|
field string,
|
|
raw string,
|
|
sanitizeFn func() string,
|
|
dest *string,
|
|
) {
|
|
sanitized := sanitizeFn()
|
|
if sanitized != raw {
|
|
message := "sanitization modified field value from external provider"
|
|
if sanitized == "" {
|
|
message = "sanitization cleared field value from external provider"
|
|
}
|
|
slog.Warn(message,
|
|
"provider_type", SanitizeString(providerType, 64),
|
|
"provider", SanitizeString(providerName, 128),
|
|
"field", SanitizeString(field, 64),
|
|
)
|
|
}
|
|
*dest = sanitized
|
|
}
|
|
|
|
var reservedUserIdentifiers = map[string]struct{}{
|
|
"all": {},
|
|
"new": {},
|
|
"id": {},
|
|
CtxSystemAdminId: {},
|
|
CtxUnknownUserId: {},
|
|
CtxSystemLdapSyncer: {},
|
|
CtxSystemWgImporter: {},
|
|
CtxSystemV1Migrator: {},
|
|
CtxSystemDBMigrator: {},
|
|
}
|
|
|
|
// SanitizeString trims leading and trailing whitespace, strips Unicode control and format
|
|
// characters, drops invalid UTF-8 bytes, normalizes to NFC, and truncates the result to
|
|
// maxLen runes. If maxLen <= 0, returns "".
|
|
//
|
|
// The order matters and is load-bearing for idempotency: see the comments in the body.
|
|
// SanitizeString(SanitizeString(s, n), n) == SanitizeString(s, n) for all s and n.
|
|
func SanitizeString(s string, maxLen int) string {
|
|
if maxLen <= 0 {
|
|
return ""
|
|
}
|
|
|
|
s = strings.TrimSpace(s)
|
|
|
|
// Strip control/format characters and invalid UTF-8 *before* normalizing.
|
|
// Normalizing first is not idempotent: removing a character can leave a
|
|
// base letter next to a combining mark that the earlier normalization never
|
|
// saw as a pair. For example "A\t̀" is already NFC (the tab keeps the
|
|
// letter and the combining grave apart), but stripping the tab yields
|
|
// "À", which a second call would compose to "À".
|
|
var b strings.Builder
|
|
b.Grow(len(s))
|
|
for len(s) > 0 {
|
|
r, size := utf8.DecodeRuneInString(s)
|
|
s = s[size:]
|
|
if r == utf8.RuneError && size == 1 {
|
|
continue
|
|
}
|
|
if !unicode.IsControl(r) && !unicode.Is(unicode.Cf, r) {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
|
|
// Normalize before truncating, not after: composition can change the rune
|
|
// count, so normalizing afterwards could push the result back over maxLen.
|
|
s = norm.NFC.String(b.String())
|
|
|
|
if utf8.RuneCountInString(s) > maxLen {
|
|
runes := []rune(s)
|
|
s = string(runes[:maxLen])
|
|
}
|
|
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
// SanitizeEmail applies SanitizeString first, then returns "" if the original s
|
|
// contains CR/LF or if the sanitized result is not a plain email address.
|
|
func SanitizeEmail(s string, maxLen int) string {
|
|
if strings.ContainsRune(s, '\r') || strings.ContainsRune(s, '\n') {
|
|
return ""
|
|
}
|
|
|
|
sanitized := SanitizeString(s, maxLen)
|
|
|
|
if sanitized == "" || strings.Count(sanitized, "@") != 1 {
|
|
return ""
|
|
}
|
|
addr, err := mail.ParseAddress(sanitized)
|
|
if err != nil || addr.Name != "" || addr.Address != sanitized {
|
|
return ""
|
|
}
|
|
|
|
return sanitized
|
|
}
|
|
|
|
// SanitizePhone applies SanitizeString first, then removes all characters not in the
|
|
// set [0-9+\-() .]. Returns "" if the result after filtering is empty.
|
|
func SanitizePhone(s string, maxLen int) string {
|
|
sanitized := SanitizeString(s, maxLen)
|
|
|
|
// Remove all characters not in [0-9+\-() .]
|
|
var b strings.Builder
|
|
b.Grow(len(sanitized))
|
|
for _, r := range sanitized {
|
|
if isAllowedPhoneRune(r) {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
result := strings.TrimSpace(b.String())
|
|
|
|
if result == "" {
|
|
return ""
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// isAllowedPhoneRune reports whether r is in the allowed phone character set [0-9+\-() .].
|
|
func isAllowedPhoneRune(r rune) bool {
|
|
switch {
|
|
case r >= '0' && r <= '9':
|
|
return true
|
|
case r == '+', r == '-', r == '(', r == ')', r == ' ', r == '.':
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// SanitizeIdentifier applies SanitizeString first, then returns "" if the result equals
|
|
// a reserved user identifier (case-sensitive, exact match).
|
|
func SanitizeIdentifier(s string, maxLen int) string {
|
|
sanitized := SanitizeString(s, maxLen)
|
|
|
|
if IsReservedUserIdentifier(UserIdentifier(sanitized)) {
|
|
return ""
|
|
}
|
|
|
|
return sanitized
|
|
}
|
|
|
|
func IsReservedUserIdentifier(identifier UserIdentifier) bool {
|
|
_, reserved := reservedUserIdentifiers[string(identifier)]
|
|
return reserved
|
|
}
|