You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
207 lines
4.9 KiB
Go
207 lines
4.9 KiB
Go
// Package security provides security utilities for the KnowFoolery application.
|
|
package security
|
|
|
|
import (
|
|
"html"
|
|
"regexp"
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
const (
|
|
// PlayerNameMaxLength defines the maximum length for player names.
|
|
PlayerNameMaxLength = 50
|
|
// AnswerMaxLength defines the maximum length for answer submissions.
|
|
AnswerMaxLength = 500
|
|
// QuestionTextMaxLength defines the maximum length for admin-authored question text.
|
|
QuestionTextMaxLength = 1000
|
|
// ThemeMaxLength defines the maximum length for theme names.
|
|
ThemeMaxLength = 100
|
|
)
|
|
|
|
var (
|
|
// Sanitization order is trim -> collapse spaces -> HTML escape -> clamp -> allowed pattern.
|
|
spaceRegex = regexp.MustCompile(`\s+`)
|
|
|
|
// Player names allow letters, numbers, spaces, '-', '_' and '.'.
|
|
playerNameAllowedPattern = regexp.MustCompile(`^[a-zA-Z0-9\s\-_.]+$`)
|
|
// Themes allow letters, numbers, spaces, '-' and '_'.
|
|
themeAllowedPattern = regexp.MustCompile(`^[a-zA-Z0-9\s\-_]+$`)
|
|
|
|
tagRegex = regexp.MustCompile(`<[^>]*>`)
|
|
scriptRegex = regexp.MustCompile(`(?i)<script[^>]*>.*?</script>`)
|
|
|
|
dangerousPatterns = []string{
|
|
"javascript:",
|
|
"data:",
|
|
"vbscript:",
|
|
"<script",
|
|
"</script",
|
|
"onerror",
|
|
"onload",
|
|
"onclick",
|
|
"onmouseover",
|
|
}
|
|
)
|
|
|
|
// SanitizeOptions configures sanitization behavior.
|
|
type SanitizeOptions struct {
|
|
TrimWhitespace bool
|
|
RemoveMultipleSpaces bool
|
|
HTMLEscape bool
|
|
MaxLength int
|
|
AllowedPattern *regexp.Regexp
|
|
}
|
|
|
|
// DefaultSanitizeOptions returns default sanitization options.
|
|
func DefaultSanitizeOptions() SanitizeOptions {
|
|
return SanitizeOptions{
|
|
TrimWhitespace: true,
|
|
RemoveMultipleSpaces: true,
|
|
HTMLEscape: true,
|
|
MaxLength: 0, // No limit
|
|
}
|
|
}
|
|
|
|
// Sanitize sanitizes a string according to the given options.
|
|
func Sanitize(input string, opts SanitizeOptions) string {
|
|
result := input
|
|
|
|
// Trim whitespace
|
|
if opts.TrimWhitespace {
|
|
result = strings.TrimSpace(result)
|
|
}
|
|
|
|
// Remove multiple consecutive spaces
|
|
if opts.RemoveMultipleSpaces {
|
|
result = spaceRegex.ReplaceAllString(result, " ")
|
|
}
|
|
|
|
// HTML escape
|
|
if opts.HTMLEscape {
|
|
result = html.EscapeString(result)
|
|
}
|
|
|
|
// Apply maximum length
|
|
if opts.MaxLength > 0 {
|
|
result = clampByRunes(result, opts.MaxLength)
|
|
}
|
|
|
|
// Validate against allowed pattern
|
|
if opts.AllowedPattern != nil && !opts.AllowedPattern.MatchString(result) {
|
|
return ""
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// SanitizePlayerName sanitizes a player name.
|
|
func SanitizePlayerName(input string) string {
|
|
opts := SanitizeOptions{
|
|
TrimWhitespace: true,
|
|
RemoveMultipleSpaces: true,
|
|
HTMLEscape: true,
|
|
MaxLength: PlayerNameMaxLength,
|
|
AllowedPattern: playerNameAllowedPattern,
|
|
}
|
|
return Sanitize(input, opts)
|
|
}
|
|
|
|
// SanitizeAnswer sanitizes an answer submission.
|
|
func SanitizeAnswer(input string) string {
|
|
opts := SanitizeOptions{
|
|
TrimWhitespace: true,
|
|
RemoveMultipleSpaces: true,
|
|
HTMLEscape: true,
|
|
MaxLength: AnswerMaxLength,
|
|
}
|
|
|
|
result := Sanitize(input, opts)
|
|
|
|
// Normalize to lowercase for comparison
|
|
result = strings.ToLower(result)
|
|
|
|
return result
|
|
}
|
|
|
|
// SanitizeQuestionText sanitizes question text (admin input).
|
|
func SanitizeQuestionText(input string) string {
|
|
opts := SanitizeOptions{
|
|
TrimWhitespace: true,
|
|
RemoveMultipleSpaces: true,
|
|
HTMLEscape: true,
|
|
MaxLength: QuestionTextMaxLength,
|
|
}
|
|
|
|
result := Sanitize(input, opts)
|
|
|
|
// Remove potential script content
|
|
result = scriptRegex.ReplaceAllString(result, "")
|
|
|
|
return result
|
|
}
|
|
|
|
// SanitizeTheme sanitizes a theme name.
|
|
func SanitizeTheme(input string) string {
|
|
opts := SanitizeOptions{
|
|
TrimWhitespace: true,
|
|
RemoveMultipleSpaces: true,
|
|
HTMLEscape: true,
|
|
MaxLength: ThemeMaxLength,
|
|
AllowedPattern: themeAllowedPattern,
|
|
}
|
|
|
|
result := Sanitize(input, opts)
|
|
|
|
// Title case
|
|
words := strings.Fields(result)
|
|
for i, word := range words {
|
|
if len(word) > 0 {
|
|
runes := []rune(word)
|
|
runes[0] = unicode.ToUpper(runes[0])
|
|
for j := 1; j < len(runes); j++ {
|
|
runes[j] = unicode.ToLower(runes[j])
|
|
}
|
|
words[i] = string(runes)
|
|
}
|
|
}
|
|
|
|
return strings.Join(words, " ")
|
|
}
|
|
|
|
// RemoveHTMLTags removes all HTML tags from a string.
|
|
func RemoveHTMLTags(input string) string {
|
|
return tagRegex.ReplaceAllString(input, "")
|
|
}
|
|
|
|
// ContainsDangerousPatterns checks if input contains potentially dangerous patterns.
|
|
func ContainsDangerousPatterns(input string) bool {
|
|
lowerInput := strings.ToLower(input)
|
|
for _, pattern := range dangerousPatterns {
|
|
if strings.Contains(lowerInput, pattern) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// IsValidEmail performs basic email validation.
|
|
func IsValidEmail(email string) bool {
|
|
emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
|
return emailRegex.MatchString(email)
|
|
}
|
|
|
|
func clampByRunes(input string, max int) string {
|
|
if max <= 0 {
|
|
return input
|
|
}
|
|
|
|
runes := []rune(input)
|
|
if len(runes) <= max {
|
|
return input
|
|
}
|
|
|
|
return string(runes[:max])
|
|
}
|