Files
wg-portal/internal/domain/sanitize.go

152 lines
3.7 KiB
Go
Raw Normal View History

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 normalizes to NFC, trims leading and trailing whitespace, strips Unicode
// control and format characters, drops invalid UTF-8 bytes, and truncates the result to
// maxLen runes. If maxLen <= 0, returns "".
func SanitizeString(s string, maxLen int) string {
if maxLen <= 0 {
return ""
}
s = norm.NFC.String(strings.TrimSpace(s))
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)
}
}
s = 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
}