package lagoon import ( "encoding/csv" "encoding/json" "fmt" "io" "math" "math/big" "net" "net/http" "reflect" "regexp" "slices" "strconv" "strings" "time" "unicode/utf8" ) // implicitRuleNames run even when the attribute is absent or blank, and a // failure of one stops the attribute (Laravel's implicitRules). var implicitRuleNames = []string{"required", "present", "filled", "accepted"} // numericRuleNames make the size rules compare the value as a number. var numericRuleNames = []string{"numeric", "integer"} var sizeRuleNames = map[string]bool{"size": true, "between": true, "min": true, "max": true} // imageExtensions is Laravel's image rule: mimes:jpg,jpeg,png,gif,bmp,svg,webp. var imageExtensions = []string{"jpg", "jpeg", "png", "gif", "bmp", "svg", "webp"} // requestRuleArity lists every rule ValidateRequest implements with its // parameter count: -1 any number (at least one), 0 none. var requestRuleArity = map[string]int{ "required": 0, "present": 0, "filled": 0, "accepted": 0, "nullable": 0, "sometimes": 0, "bail": 0, "array": -2, "string": 0, "integer": 0, "numeric": 0, "boolean": 0, "email": 0, "url": 0, "date": 0, "after": 1, "after_or_equal": 1, "before": 1, "before_or_equal": 1, "exists": -1, "regex": 1, "not_regex": 1, "in": -1, "not_in": -1, "mimes": -1, "image": 0, "file": 0, "min": 1, "max": 1, "size": 1, "between": 2, } func isImplicitName(name string) bool { for _, n := range implicitRuleNames { if n == name { return true } } return false } func (r Rule) isImplicit() bool { return r.custom == nil && isImplicitName(r.name) } type compiledRegex struct { re *regexp.Regexp } // ParseRules parses a Laravel rule string such as "required|string|max:255" // into rules. Parameters are split like PHP's str_getcsv (`in:LP,"EP 7"""`); // a regex: or not_regex: parameter is kept whole, pipes and commas included, // up to its closing PCRE delimiter. ParseRules is meant for rule tables built // at package initialization: an unknown rule, a wrong parameter count, an // unsafe exists: identifier or a pattern Go's RE2 cannot compile panics, so a // broken table fails at boot and never at request time. func ParseRules(spec string) []Rule { var out []Rule for _, tok := range splitRuleSpec(spec) { out = append(out, mustParseRule(tok)) } return out } func splitRuleSpec(spec string) []string { var out []string rest := spec for rest != "" { trimmed := strings.TrimLeft(rest, " \t") if strings.HasPrefix(trimmed, "regex:") || strings.HasPrefix(trimmed, "not_regex:") { name, pat, _ := strings.Cut(trimmed, ":") end := regexTokenEnd(pat) out = append(out, name+":"+pat[:end]) rest = pat[end:] rest = strings.TrimPrefix(rest, "|") continue } tok, after, found := strings.Cut(rest, "|") if t := strings.TrimSpace(tok); t != "" { out = append(out, t) } if !found { break } rest = after } return out } // regexTokenEnd finds where a PCRE literal ends inside a rule string: at the // first unescaped closing delimiter that is followed by modifiers and then a // pipe or the end of the string. func regexTokenEnd(pat string) int { if pat == "" { return 0 } open, size := utf8.DecodeRuneInString(pat) closing := closingDelimiter(open) for i := size; i < len(pat); i++ { c := pat[i] if c == '\\' { i++ continue } if rune(c) != closing { continue } j := i + 1 for j < len(pat) && isPCREModifier(pat[j]) { j++ } if j == len(pat) || pat[j] == '|' { return j } } if k := strings.Index(pat, "|"); k >= 0 { return k } return len(pat) } func closingDelimiter(open rune) rune { switch open { case '(': return ')' case '[': return ']' case '{': return '}' case '<': return '>' } return open } func isPCREModifier(c byte) bool { return strings.IndexByte("imsxuADSUXJn", c) >= 0 } func mustParseRule(tok string) Rule { name, param, hasParam := strings.Cut(tok, ":") name = strings.ToLower(strings.TrimSpace(name)) switch name { case "int": name = "integer" case "bool": name = "boolean" } arity, known := requestRuleArity[name] if !known { panic(fmt.Sprintf("lagoon: ParseRules: unsupported rule %q", tok)) } var args []string if hasParam { if name == "regex" || name == "not_regex" { args = []string{param} } else { args = phpGetCSV(param) } } switch { case arity == 0 && len(args) > 0: panic(fmt.Sprintf("lagoon: ParseRules: rule %q takes no parameters", tok)) case arity > 0 && len(args) != arity: panic(fmt.Sprintf("lagoon: ParseRules: rule %q needs %d parameter(s)", tok, arity)) case arity == -1 && len(args) == 0: panic(fmt.Sprintf("lagoon: ParseRules: rule %q needs parameters", tok)) } r := Rule{name: name, args: args} switch name { case "min", "max", "size", "between": for _, a := range args { if _, ok := new(big.Rat).SetString(strings.TrimSpace(a)); !ok { panic(fmt.Sprintf("lagoon: ParseRules: rule %q has a non-numeric parameter", tok)) } } case "regex", "not_regex": re, err := compilePCRE(args[0]) if err != nil { panic(fmt.Sprintf("lagoon: ParseRules: rule %q: %v", tok, err)) } r.re = &compiledRegex{re: re} case "exists": if len(args) > 2 { panic(fmt.Sprintf("lagoon: ParseRules: rule %q: extra where clauses are not supported", tok)) } table := args[0] if i := strings.LastIndex(table, "."); i >= 0 { table = table[i+1:] } if !identName.MatchString(table) { panic(fmt.Sprintf("lagoon: ParseRules: rule %q: unsafe table name", tok)) } r.args[0] = table if len(args) == 2 && args[1] != "NULL" && !identName.MatchString(args[1]) { panic(fmt.Sprintf("lagoon: ParseRules: rule %q: unsafe column name", tok)) } } return r } // phpGetCSV splits a rule parameter list like PHP's str_getcsv: commas // separate fields, a field may be double-quoted with "" as an escaped quote, // and a quote inside an unquoted field is kept. func phpGetCSV(s string) []string { r := csv.NewReader(strings.NewReader(s)) r.LazyQuotes = true r.FieldsPerRecord = -1 rec, err := r.Read() if err != nil { return []string{s} } return rec } // compilePCRE turns a PCRE literal (/pattern/flags) into a Go regexp. The i, // m, s, u and D modifiers are supported (u is implied, D is Go's default end // anchoring); any other modifier is an error. func compilePCRE(lit string) (*regexp.Regexp, error) { if len(lit) < 2 { return nil, fmt.Errorf("pattern %q has no delimiters", lit) } open, size := utf8.DecodeRuneInString(lit) if open == '\\' || open == utf8.RuneError || (open < 128 && (isAlnumByte(byte(open)) || open == ' ')) { return nil, fmt.Errorf("pattern %q has an invalid delimiter", lit) } closing := closingDelimiter(open) end := strings.LastIndex(lit, string(closing)) if end < size { return nil, fmt.Errorf("pattern %q has no closing delimiter", lit) } body := lit[size:end] flags := "" for _, m := range lit[end+1:] { switch m { case 'i', 'm', 's': if !strings.ContainsRune(flags, m) { flags += string(m) } case 'u', 'D': default: return nil, fmt.Errorf("pattern %q uses the unsupported modifier %q", lit, m) } } if open == closing { body = strings.ReplaceAll(body, `\`+string(open), string(open)) } if flags != "" { body = "(?" + flags + ")" + body } return regexp.Compile(body) } func isAlnumByte(c byte) bool { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') } func (v *requestValidator) passes(rule Rule, attr string, value any, present bool) (bool, error) { switch rule.name { case "required": return validateRequired(value), nil case "present": return present, nil case "filled": return !present || validateRequired(value), nil case "accepted": return validateRequired(value) && isAccepted(value), nil case "nullable", "sometimes", "bail": return true, nil case "array": return validateArray(value, rule.args), nil case "string": _, ok := value.(string) return ok, nil case "integer": return filterInt(value), nil case "numeric": return isNumeric(value), nil case "boolean": return isStrictBoolean(value), nil case "email": s, ok := value.(string) return ok && filterEmail(s), nil case "url": s, ok := value.(string) return ok && urlPattern.MatchString(s), nil case "date": return validateDate(value), nil case "after": return v.compareDates(value, rule.args[0], ">"), nil case "after_or_equal": return v.compareDates(value, rule.args[0], ">="), nil case "before": return v.compareDates(value, rule.args[0], "<"), nil case "before_or_equal": return v.compareDates(value, rule.args[0], "<="), nil case "exists": return v.exists(attr, rule, value) case "regex", "not_regex": s, ok := regexSubject(value) if !ok { return false, nil } matched := rule.re.re.MatchString(s) if rule.name == "regex" { return matched, nil } return !matched, nil case "in": return v.validateIn(attr, value, rule.args), nil case "not_in": return v.validateNotIn(attr, value, rule.args), nil case "file": _, ok := asUploadedFile(value) return ok, nil case "image": return validateMimes(value, imageExtensions), nil case "mimes": return validateMimes(value, rule.args), nil case "min", "max", "size", "between": size, ok := v.size(attr, value) if !ok { return false, nil } return sizeInRange(rule, size), nil } return false, fmt.Errorf("lagoon: rule %q is not implemented", rule.name) } func sizeInRange(rule Rule, size *big.Rat) bool { bound := func(i int) *big.Rat { r, _ := new(big.Rat).SetString(strings.TrimSpace(rule.args[i])) return r } switch rule.name { case "min": return size.Cmp(bound(0)) >= 0 case "max": return size.Cmp(bound(0)) <= 0 case "size": return size.Cmp(bound(0)) == 0 default: // between return size.Cmp(bound(0)) >= 0 && size.Cmp(bound(1)) <= 0 } } // size is Laravel's getSize: the number itself under a numeric rule, the // element count of an array, kilobytes of a file, else the length of the // string form in characters (PHP mb_strlen). func (v *requestValidator) size(attr string, value any) (*big.Rat, bool) { if isNumeric(value) && v.hasRule(attr, numericRuleNames...) { s, _ := phpScalarString(value) r, ok := new(big.Rat).SetString(phpTrim(s)) return r, ok } if n, ok := arrayLen(value); ok { return new(big.Rat).SetInt64(int64(n)), true } if f, ok := asUploadedFile(value); ok { return new(big.Rat).SetFrac64(f.Size, 1024), true } if value == nil { return new(big.Rat), true } s, ok := phpScalarString(value) if !ok { return nil, false } return new(big.Rat).SetInt64(int64(utf8.RuneCountInString(s))), true } func validateRequired(value any) bool { switch t := value.(type) { case nil: return false case string: return phpTrim(t) != "" } if n, ok := arrayLen(value); ok { return n > 0 } if f, ok := asUploadedFile(value); ok { return f.Filename != "" || f.Size > 0 } return true } func isAccepted(value any) bool { switch t := value.(type) { case bool: return t case string: return t == "yes" || t == "on" || t == "1" || t == "true" case json.Number: return string(t) == "1" case float64: return t == 1 case int: return t == 1 case int64: return t == 1 } return false } func validateArray(value any, keys []string) bool { if _, ok := arrayLen(value); !ok { return false } if len(keys) == 0 { return true } allowed := map[string]bool{} for _, k := range keys { allowed[k] = true } for _, ch := range children(value) { if !allowed[ch.key] { return false } } return true } func arrayLen(value any) (int, bool) { switch t := value.(type) { case []any: return len(t), true case map[string]any: return len(t), true case []string: return len(t), true case []map[string]any: return len(t), true } rv := reflect.ValueOf(value) if rv.Kind() == reflect.Slice || rv.Kind() == reflect.Map { return rv.Len(), true } return 0, false } func isStrictBoolean(value any) bool { switch t := value.(type) { case bool: return true case string: return t == "0" || t == "1" case json.Number: return string(t) == "0" || string(t) == "1" case float64: return t == 0 || t == 1 case int: return t == 0 || t == 1 case int64: return t == 0 || t == 1 } return false } // isNumeric ports PHP's is_numeric: numbers, and strings holding a decimal // or exponent number with optional surrounding whitespace. func isNumeric(value any) bool { switch t := value.(type) { case string: return isNumericString(t) case json.Number: return isNumericString(string(t)) case float32: return true case float64: return true case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: return true } return false } func isNumericString(s string) bool { s = strings.TrimLeft(s, " \t\n\r\v\f") s = strings.TrimRight(s, " \t\n\r\v\f") if s == "" { return false } i := 0 if s[i] == '+' || s[i] == '-' { i++ } digits := 0 for i < len(s) && s[i] >= '0' && s[i] <= '9' { i++ digits++ } if i < len(s) && s[i] == '.' { i++ for i < len(s) && s[i] >= '0' && s[i] <= '9' { i++ digits++ } } if digits == 0 { return false } if i < len(s) && (s[i] == 'e' || s[i] == 'E') { i++ if i < len(s) && (s[i] == '+' || s[i] == '-') { i++ } exp := 0 for i < len(s) && s[i] >= '0' && s[i] <= '9' { i++ exp++ } if exp == 0 { return false } } return i == len(s) } // filterInt ports filter_var($value, FILTER_VALIDATE_INT) !== false: the // string form, trimmed, must be an optionally signed decimal integer with no // leading zero that fits in 64 bits. true counts as 1. func filterInt(value any) bool { var s string switch t := value.(type) { case bool: return t case nil: return false case int, int8, int16, int32, int64, uint8, uint16, uint32: return true case uint: return uint64(t) <= math.MaxInt64 case uint64: return t <= math.MaxInt64 case string: s = t case json.Number: s = string(t) if f, err := strconv.ParseFloat(s, 64); err == nil && strings.ContainsAny(s, ".eE") { s = phpFloatString(f) } case float32: s = phpFloatString(float64(t)) case float64: s = phpFloatString(t) default: return false } s = strings.Trim(s, " \t\r\n\v\x00") if s == "" { return false } body := s if body[0] == '+' || body[0] == '-' { body = body[1:] } if body == "" { return false } for i := 0; i < len(body); i++ { if body[i] < '0' || body[i] > '9' { return false } } if len(body) > 1 && body[0] == '0' { return false } _, err := strconv.ParseInt(s, 10, 64) return err == nil } // phpScalarString is PHP's (string) cast for scalars. func phpScalarString(value any) (string, bool) { switch t := value.(type) { case nil: return "", true case string: return t, true case json.Number: if _, err := strconv.ParseInt(string(t), 10, 64); err == nil { return string(t), true } if f, err := strconv.ParseFloat(string(t), 64); err == nil { return phpFloatString(f), true } return string(t), true case bool: if t { return "1", true } return "", true case float32: return phpFloatString(float64(t)), true case float64: return phpFloatString(t), true case int: return strconv.Itoa(t), true case int8, int16, int32, int64: return strconv.FormatInt(reflect.ValueOf(t).Int(), 10), true case uint, uint8, uint16, uint32, uint64: return strconv.FormatUint(reflect.ValueOf(t).Uint(), 10), true } return "", false } // phpFloatString formats a float as PHP 8 casts it to string: %.14G with // the default precision ini of 14 (zend_gcvt). The value is correctly // rounded to 14 significant digits and trailing zeros are dropped; it is // written in exponent form (1.0E+15, 1.5E-5) when the decimal point would // sit more than 14 digits right or more than 3 zeros left of the digits. func phpFloatString(f float64) string { switch { case math.IsNaN(f): return "NAN" case math.IsInf(f, 1): return "INF" case math.IsInf(f, -1): return "-INF" case f == 0: if math.Signbit(f) { return "-0" } return "0" } const precision = 14 e := strconv.FormatFloat(math.Abs(f), 'e', precision-1, 64) mant, expStr, _ := strings.Cut(e, "e") exp, _ := strconv.Atoi(expStr) digits := strings.TrimRight(strings.Replace(mant, ".", "", 1), "0") if digits == "" { digits = "0" } decpt := exp + 1 var out string switch { case decpt < -3 || decpt > precision: m := digits[:1] + ".0" if len(digits) > 1 { m = digits[:1] + "." + digits[1:] } sign := "+" if exp < 0 { sign, exp = "-", -exp } out = m + "E" + sign + strconv.Itoa(exp) case decpt <= 0: out = "0." + strings.Repeat("0", -decpt) + digits case decpt >= len(digits): out = digits + strings.Repeat("0", decpt-len(digits)) default: out = digits[:decpt] + "." + digits[decpt:] } if f < 0 { out = "-" + out } return out } // phpTrim trims the characters PHP's trim() does. func phpTrim(s string) string { return strings.Trim(s, " \t\n\r\x00\x0B") } func regexSubject(value any) (string, bool) { if s, ok := value.(string); ok { return s, true } if isNumeric(value) { return phpScalarString(value) } return "", false } func asUploadedFile(value any) (UploadedFile, bool) { switch t := value.(type) { case UploadedFile: return t, true case *UploadedFile: if t != nil { return *t, true } } return UploadedFile{}, false } // in compares like PHP's in_array((string) $value, $parameters): two numeric // strings compare as numbers, anything else as bytes. func phpLooseStringEqual(a, b string) bool { if a == b { return true } if isNumericString(a) && isNumericString(b) { ra, ok1 := new(big.Rat).SetString(phpTrim(a)) rb, ok2 := new(big.Rat).SetString(phpTrim(b)) return ok1 && ok2 && ra.Cmp(rb) == 0 } return false } func inList(s string, list []string) bool { for _, p := range list { if phpLooseStringEqual(s, p) { return true } } return false } // validateIn is Laravel's validateIn. An array value needs the array rule // and no nested element, and then, like count(array_diff($value, // $parameters)) === 0, every element's string form must equal a parameter // exactly. A scalar compares like in_array((string) $value, $parameters), // where two numeric strings compare as numbers. func (v *requestValidator) validateIn(attr string, value any, params []string) bool { if _, isArr := arrayLen(value); isArr { if !v.hasRule(attr, "array") { return false } for _, ch := range children(value) { if _, nested := arrayLen(ch.value); nested { return false } } for _, ch := range children(value) { s, ok := phpScalarString(ch.value) if !ok || !slices.Contains(params, s) { return false } } return true } s, ok := phpScalarString(value) return ok && inList(s, params) } // validateNotIn is Laravel's validateNotIn: the negation of validateIn, so // an array passes unless every element is listed, and an array without the // array rule always passes. func (v *requestValidator) validateNotIn(attr string, value any, params []string) bool { return !v.validateIn(attr, value, params) } // exists is Laravel's exists:table,column presence check. It counts rows // whose column, compared as text, equals the value (or, for an array, every // distinct value); Laravel adds no deleted_at condition and neither does this. func (v *requestValidator) exists(attr string, rule Rule, value any) (bool, error) { if v.tx == nil { return false, fmt.Errorf("lagoon: exists:%s requires a database handle", rule.args[0]) } table := rule.args[0] column := attr if len(rule.args) == 2 && rule.args[1] != "NULL" { column = rule.args[1] } else if _, expanded := v.primary[attr]; expanded { segs := strings.Split(attr, ".") if last := segs[len(segs)-1]; !isNumericString(last) { column = last } } if !identName.MatchString(table) || !identName.MatchString(column) { return false, fmt.Errorf("lagoon: exists identifier %q.%q is not safe", table, column) } db := v.tx.WithContext(v.ctx) if _, isArr := arrayLen(value); isArr { uniq := map[string]bool{} var vals []string for _, ch := range children(value) { s, ok := phpScalarString(ch.value) if !ok { return false, nil } if !uniq[s] { uniq[s] = true vals = append(vals, s) } } if len(vals) == 0 { return true, nil } var n int64 err := db.Table(table).Where("CAST("+column+" AS TEXT) IN ?", vals). Distinct("CAST(" + column + " AS TEXT)").Count(&n).Error if err != nil { return false, err } return n >= int64(len(vals)), nil } s, ok := phpScalarString(value) if !ok { return false, nil } var n int64 if err := db.Table(table).Where("CAST("+column+" AS TEXT) = ?", s).Count(&n).Error; err != nil { return false, err } return n >= 1, nil } // validateMimes sniffs the uploaded content (http.DetectContentType, plus // SVG detection) and maps the type to an extension the way Symfony's // guessExtension does; jpg and jpeg stand for each other, and a client file // name ending in a PHP extension is refused unless php is allowed. func validateMimes(value any, allowed []string) bool { f, ok := asUploadedFile(value) if !ok || f.Open == nil { return false } params := make([]string, 0, len(allowed)+2) hasJPEG, hasPHP := false, false for _, a := range allowed { a = strings.ToLower(strings.TrimSpace(a)) params = append(params, a) hasJPEG = hasJPEG || a == "jpg" || a == "jpeg" hasPHP = hasPHP || a == "php" } if hasJPEG { params = append(params, "jpg", "jpeg") } if !hasPHP { ext := strings.ToLower(strings.TrimSpace(fileExtension(f.Filename))) switch ext { case "php", "php3", "php4", "php5", "php7", "php8", "phtml", "phar": return false } } guessed := guessExtension(f) if guessed == "" { return false } for _, p := range params { if p == guessed { return true } } return false } func fileExtension(name string) string { if i := strings.LastIndex(name, "."); i >= 0 { return name[i+1:] } return "" } var mimeExtensions = map[string]string{ "image/jpeg": "jpg", "image/png": "png", "image/gif": "gif", "image/webp": "webp", "image/bmp": "bmp", "image/x-ms-bmp": "bmp", "image/svg+xml": "svg", "image/x-icon": "ico", "image/vnd.microsoft.icon": "ico", "image/avif": "avif", "application/pdf": "pdf", "application/zip": "zip", "application/x-gzip": "gz", "application/x-rar-compressed": "rar", "application/ogg": "ogx", "application/wasm": "wasm", "application/octet-stream": "bin", "application/json": "json", "text/plain": "txt", "text/html": "html", "text/xml": "xml", "text/css": "css", "audio/mpeg": "mp3", "audio/wave": "wav", "audio/aiff": "aif", "video/mp4": "mp4", "video/webm": "webm", "video/avi": "avi", "font/woff": "woff", "font/woff2": "woff2", "font/ttf": "ttf", "font/otf": "otf", } func guessExtension(f UploadedFile) string { rc, err := f.Open() if err != nil { return "" } defer rc.Close() head := make([]byte, 512) n, err := io.ReadFull(rc, head) if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { return "" } head = head[:n] if n == 0 { return "" } mime, _, _ := strings.Cut(http.DetectContentType(head), ";") mime = strings.TrimSpace(mime) if strings.HasPrefix(mime, "text/") && looksLikeSVG(head) { mime = "image/svg+xml" } return mimeExtensions[mime] } func looksLikeSVG(head []byte) bool { s := strings.ToLower(string(head)) return strings.Contains(s, " *b: c = 1 } default: ab := a != nil && *a != 0 bb := b != nil && *b != 0 switch { case !ab && bb: c = -1 case ab && !bb: c = 1 } } switch op { case ">": return c > 0 case ">=": return c >= 0 case "<": return c < 0 default: return c <= 0 } } // requestNow is the clock for relative date words; tests replace it. var requestNow = time.Now // parseDateArg parses a date rule parameter or value the way Carbon::parse // does for the shapes parseDateValue accepts, plus the relative words today, // tomorrow, yesterday and now (midnight or the current time, in UTC). func parseDateArg(s string) (time.Time, bool) { now := requestNow().UTC() midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) switch strings.ToLower(strings.TrimSpace(s)) { case "now": return now, true case "today", "midnight": return midnight, true case "tomorrow": return midnight.AddDate(0, 0, 1), true case "yesterday": return midnight.AddDate(0, 0, -1), true } return parseDateValue(s) } var ( dateISO = regexp.MustCompile(`^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ](\d{1,2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?)?\s*(Z|[+-]\d{2}(?::?\d{2})?)?$`) dateSlash = regexp.MustCompile(`^(\d{4})/(\d{1,2})/(\d{1,2})$`) dateUS = regexp.MustCompile(`^(\d{1,2})/(\d{1,2})/(\d{4})$`) dateDot = regexp.MustCompile(`^(\d{1,2})[.-](\d{1,2})[.-](\d{4})$`) ) // parseDateValue accepts the date shapes the API clients send, all read in // UTC unless an offset is given: Y-m-d, Y-m-d H:i[:s[.u]] and the ISO 8601 // form with a T, Z or an offset; Y/m/d; m/d/Y (strtotime's American slash // order); d.m.Y and d-m-Y. The calendar date must exist (checkdate), so // 2023-02-30 is refused. Other strtotime inputs are refused. func parseDateValue(s string) (time.Time, bool) { s = strings.TrimSpace(s) var y, mo, d, h, mi, sec, nsec int loc := time.UTC switch { case dateISO.MatchString(s): m := dateISO.FindStringSubmatch(s) y, mo, d = atoi(m[1]), atoi(m[2]), atoi(m[3]) if m[4] != "" { h, mi = atoi(m[4]), atoi(m[5]) } if m[6] != "" { sec = atoi(m[6]) } if m[7] != "" { frac := (m[7] + "000000000")[:9] nsec = atoi(frac) } if z := m[8]; z != "" && z != "Z" { sign := 1 if z[0] == '-' { sign = -1 } digits := strings.ReplaceAll(z[1:], ":", "") oh := atoi(digits[:2]) om := 0 if len(digits) == 4 { om = atoi(digits[2:]) } if oh > 23 || om > 59 { return time.Time{}, false } loc = time.FixedZone("", sign*(oh*3600+om*60)) } case dateSlash.MatchString(s): m := dateSlash.FindStringSubmatch(s) y, mo, d = atoi(m[1]), atoi(m[2]), atoi(m[3]) case dateUS.MatchString(s): m := dateUS.FindStringSubmatch(s) mo, d, y = atoi(m[1]), atoi(m[2]), atoi(m[3]) case dateDot.MatchString(s): m := dateDot.FindStringSubmatch(s) d, mo, y = atoi(m[1]), atoi(m[2]), atoi(m[3]) default: return time.Time{}, false } if !checkDate(y, mo, d) || h > 23 || mi > 59 || sec > 59 { return time.Time{}, false } return time.Date(y, time.Month(mo), d, h, mi, sec, nsec, loc), true } func atoi(s string) int { n, _ := strconv.Atoi(s) return n } func checkDate(y, m, d int) bool { if y < 1 || y > 32767 || m < 1 || m > 12 || d < 1 { return false } return d <= time.Date(y, time.Month(m)+1, 0, 0, 0, 0, 0, time.UTC).Day() } // filterEmail ports PHP's FILTER_VALIDATE_EMAIL, the check Winter's email // rule uses by default: ASCII only, under 255 characters, a local part of // dot-separated atoms or quoted strings up to 64 characters, and a domain of // at least two dot-separated labels whose last starts with a letter (or is // an xn-- label), or a bracketed IPv4 or IPv6 literal. func filterEmail(s string) bool { if s == "" || len(s) >= 255 { return false } for i := 0; i < len(s); i++ { if s[i] >= 0x80 { return false } } at := emailLocalEnd(s) if at <= 0 || at >= len(s) || s[at] != '@' || at > 64 { return false } return emailDomainOK(s[at+1:]) } func isAtext(c byte) bool { switch { case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': return true } return strings.IndexByte("!#$%&'*+-/=?^_`{|}~", c) >= 0 } // emailLocalEnd parses the local part and returns the index of the @ that // ends it, or -1. func emailLocalEnd(s string) int { i := 0 for { if i >= len(s) { return -1 } if s[i] == '"' { i++ for { if i >= len(s) { return -1 } c := s[i] if c == '"' { i++ break } if c == '\\' { if i+1 >= len(s) || s[i+1] > 0x7F { return -1 } i += 2 continue } if c == 0 || c == '\t' || c == '\n' || c == '\r' || c == ' ' || c > 0x7F { return -1 } i++ } } else { start := i for i < len(s) && isAtext(s[i]) { i++ } if i == start { return -1 } } if i < len(s) && s[i] == '.' { i++ continue } if i < len(s) && s[i] == '@' { return i } return -1 } } var ( emailLabel = regexp.MustCompile(`(?i)^(?:xn--)?[a-z0-9]+(?:-+[a-z0-9]+)*$`) emailTopLabel = regexp.MustCompile(`(?i)^(?:[a-z][a-z0-9]*|xn--[a-z0-9]+)(?:-+[a-z0-9]+)*$`) emailIPv4 = regexp.MustCompile(`^(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}$`) ) func emailDomainOK(d string) bool { if strings.HasPrefix(d, "[") && strings.HasSuffix(d, "]") { lit := d[1 : len(d)-1] if len(lit) > 5 && strings.EqualFold(lit[:5], "IPv6:") { ip := net.ParseIP(lit[5:]) return ip != nil && strings.Contains(lit[5:], ":") } return emailIPv4.MatchString(lit) } labels := strings.Split(d, ".") if len(labels) < 2 || len(labels) > 127 { return false } for i, l := range labels { if len(l) >= 64 { return false } if i == len(labels)-1 { if !emailTopLabel.MatchString(l) { return false } continue } if !emailLabel.MatchString(l) { return false } } return true } // urlPattern is Laravel 9's validateUrl pattern (derived from Symfony's // UrlValidator) rewritten for RE2: the same protocols, optional basic auth, // a domain name, IPv4 or bracketed IPv6 host, optional port, path, query // and fragment, matched case-insensitively. var urlPattern = regexp.MustCompile(`(?i)^` + `(aaa|aaas|about|acap|acct|acd|acr|adiumxtra|adt|afp|afs|aim|amss|android|appdata|apt|ark|attachment|aw|barion|beshare|bitcoin|bitcoincash|blob|bolo|browserext|calculator|callto|cap|cast|casts|chrome|chrome-extension|cid|coap|coap\+tcp|coap\+ws|coaps|coaps\+tcp|coaps\+ws|com-eventbrite-attendee|content|conti|crid|cvs|dab|data|dav|diaspora|dict|did|dis|dlna-playcontainer|dlna-playsingle|dns|dntp|dpp|drm|drop|dtn|dvb|ed2k|elsi|example|facetime|fax|feed|feedready|file|filesystem|finger|first-run-pen-experience|fish|fm|ftp|fuchsia-pkg|geo|gg|git|gizmoproject|go|gopher|graph|gtalk|h323|ham|hcap|hcp|http|https|hxxp|hxxps|hydrazone|iax|icap|icon|im|imap|info|iotdisco|ipn|ipp|ipps|irc|irc6|ircs|iris|iris\.beep|iris\.lwz|iris\.xpc|iris\.xpcs|isostore|itms|jabber|jar|jms|keyparc|lastfm|ldap|ldaps|leaptofrogans|lorawan|lvlt|magnet|mailserver|mailto|maps|market|message|mid|mms|modem|mongodb|moz|ms-access|ms-browser-extension|ms-calculator|ms-drive-to|ms-enrollment|ms-excel|ms-eyecontrolspeech|ms-gamebarservices|ms-gamingoverlay|ms-getoffice|ms-help|ms-infopath|ms-inputapp|ms-lockscreencomponent-config|ms-media-stream-id|ms-mixedrealitycapture|ms-mobileplans|ms-officeapp|ms-people|ms-project|ms-powerpoint|ms-publisher|ms-restoretabcompanion|ms-screenclip|ms-screensketch|ms-search|ms-search-repair|ms-secondary-screen-controller|ms-secondary-screen-setup|ms-settings|ms-settings-airplanemode|ms-settings-bluetooth|ms-settings-camera|ms-settings-cellular|ms-settings-cloudstorage|ms-settings-connectabledevices|ms-settings-displays-topology|ms-settings-emailandaccounts|ms-settings-language|ms-settings-location|ms-settings-lock|ms-settings-nfctransactions|ms-settings-notifications|ms-settings-power|ms-settings-privacy|ms-settings-proximity|ms-settings-screenrotation|ms-settings-wifi|ms-settings-workplace|ms-spd|ms-sttoverlay|ms-transit-to|ms-useractivityset|ms-virtualtouchpad|ms-visio|ms-walk-to|ms-whiteboard|ms-whiteboard-cmd|ms-word|msnim|msrp|msrps|mss|mtqp|mumble|mupdate|mvn|news|nfs|ni|nih|nntp|notes|ocf|oid|onenote|onenote-cmd|opaquelocktoken|openpgp4fpr|pack|palm|paparazzi|payto|pkcs11|platform|pop|pres|prospero|proxy|pwid|psyc|pttp|qb|query|redis|rediss|reload|res|resource|rmi|rsync|rtmfp|rtmp|rtsp|rtsps|rtspu|s3|secondlife|service|session|sftp|sgn|shttp|sieve|simpleledger|sip|sips|skype|smb|sms|smtp|snews|snmp|soap\.beep|soap\.beeps|soldat|spiffe|spotify|ssh|steam|stun|stuns|submit|svn|tag|teamspeak|tel|teliaeid|telnet|tftp|tg|things|thismessage|tip|tn3270|tool|ts3server|turn|turns|tv|udp|unreal|urn|ut2004|v-event|vemmi|ventrilo|videotex|vnc|view-source|wais|webcal|wpid|ws|wss|wtai|wyciwyg|xcon|xcon-userid|xfire|xmlrpc\.beep|xmlrpc\.beeps|xmpp|xri|ymsgr|z39\.50|z39\.50r|z39\.50s)://` + `(((?:[_.\pL\pN-]|%[0-9A-Fa-f]{2})+:)?((?:[_.\pL\pN-]|%[0-9A-Fa-f]{2})+)@)?` + `(` + `([\pL\pN\pS\-_.])+(\.?([\pL\pN]|xn--[\pL\pN-]+)+\.?)` + `|` + `\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}` + `|` + `\[` + urlIPv6 + `\]` + `)` + `(:[0-9]+)?` + `(?:/(?:[\pL\pN\-._~!$&'()*+,;=:@]|%[0-9A-Fa-f]{2})*)*` + `(?:\?(?:[\pL\pN\-._~!$&'\[\]()*+,;=:@/?]|%[0-9A-Fa-f]{2})*)?` + `(?:#(?:[\pL\pN\-._~!$&'()*+,;=:@/?]|%[0-9A-Fa-f]{2})*)?` + `$`) const urlIPv6 = `(?:(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){6})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:::(?:(?:(?:[0-9a-f]{1,4})):){5})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){4})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,1}(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){3})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,2}(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){2})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,3}(?:(?:[0-9a-f]{1,4})))?::(?:(?:[0-9a-f]{1,4})):)(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,4}(?:(?:[0-9a-f]{1,4})))?::)(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,5}(?:(?:[0-9a-f]{1,4})))?::)(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,6}(?:(?:[0-9a-f]{1,4})))?::))))`