Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
|
|
@ -0,0 +1,263 @@
|
|||
package inputvalidation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Validation limits for input fields
|
||||
const (
|
||||
// Email limits
|
||||
MaxEmailLength = 254 // RFC 5321
|
||||
|
||||
// Name limits (collection names, file names, user names)
|
||||
MinNameLength = 1
|
||||
MaxNameLength = 255
|
||||
|
||||
// Display name limits
|
||||
MaxDisplayNameLength = 100
|
||||
|
||||
// Description limits
|
||||
MaxDescriptionLength = 1000
|
||||
|
||||
// UUID format (standard UUID v4)
|
||||
UUIDLength = 36
|
||||
|
||||
// OTT (One-Time Token) limits
|
||||
OTTLength = 8 // 8-digit code
|
||||
|
||||
// Password limits
|
||||
MinPasswordLength = 8
|
||||
MaxPasswordLength = 128
|
||||
)
|
||||
|
||||
// uuidRegex matches standard UUID format (8-4-4-4-12)
|
||||
var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||||
|
||||
// ottRegex matches 8-digit OTT codes
|
||||
var ottRegex = regexp.MustCompile(`^[0-9]{8}$`)
|
||||
|
||||
// ValidateEmail validates an email address
|
||||
func ValidateEmail(email string) error {
|
||||
if email == "" {
|
||||
return fmt.Errorf("email is required")
|
||||
}
|
||||
|
||||
// Check length
|
||||
if len(email) > MaxEmailLength {
|
||||
return fmt.Errorf("email exceeds maximum length of %d characters", MaxEmailLength)
|
||||
}
|
||||
|
||||
// Use Go's mail package for RFC 5322 validation
|
||||
_, err := mail.ParseAddress(email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid email format")
|
||||
}
|
||||
|
||||
// Additional checks for security
|
||||
if strings.ContainsAny(email, "\x00\n\r") {
|
||||
return fmt.Errorf("email contains invalid characters")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateUUID validates a UUID string
|
||||
func ValidateUUID(id, fieldName string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("%s is required", fieldName)
|
||||
}
|
||||
|
||||
if len(id) != UUIDLength {
|
||||
return fmt.Errorf("%s must be a valid UUID", fieldName)
|
||||
}
|
||||
|
||||
if !uuidRegex.MatchString(id) {
|
||||
return fmt.Errorf("%s must be a valid UUID format", fieldName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateName validates a name field (collection name, filename, etc.)
|
||||
func ValidateName(name, fieldName string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("%s is required", fieldName)
|
||||
}
|
||||
|
||||
// Check length
|
||||
if len(name) > MaxNameLength {
|
||||
return fmt.Errorf("%s exceeds maximum length of %d characters", fieldName, MaxNameLength)
|
||||
}
|
||||
|
||||
// Check for valid UTF-8
|
||||
if !utf8.ValidString(name) {
|
||||
return fmt.Errorf("%s contains invalid characters", fieldName)
|
||||
}
|
||||
|
||||
// Check for control characters (except tab and newline which might be valid in descriptions)
|
||||
for _, r := range name {
|
||||
if r < 32 && r != '\t' && r != '\n' && r != '\r' {
|
||||
return fmt.Errorf("%s contains invalid control characters", fieldName)
|
||||
}
|
||||
// Also check for null byte and other dangerous characters
|
||||
if r == 0 {
|
||||
return fmt.Errorf("%s contains null characters", fieldName)
|
||||
}
|
||||
}
|
||||
|
||||
// Check that it's not all whitespace
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return fmt.Errorf("%s cannot be empty or whitespace only", fieldName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateDisplayName validates a display name (first name, last name, etc.)
|
||||
func ValidateDisplayName(name, fieldName string) error {
|
||||
// Display names can be empty (optional fields)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check length
|
||||
if len(name) > MaxDisplayNameLength {
|
||||
return fmt.Errorf("%s exceeds maximum length of %d characters", fieldName, MaxDisplayNameLength)
|
||||
}
|
||||
|
||||
// Check for valid UTF-8
|
||||
if !utf8.ValidString(name) {
|
||||
return fmt.Errorf("%s contains invalid characters", fieldName)
|
||||
}
|
||||
|
||||
// Check for control characters
|
||||
for _, r := range name {
|
||||
if r < 32 || !unicode.IsPrint(r) {
|
||||
if r != ' ' { // Allow spaces
|
||||
return fmt.Errorf("%s contains invalid characters", fieldName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateDescription validates a description field
|
||||
func ValidateDescription(desc string) error {
|
||||
// Descriptions can be empty (optional)
|
||||
if desc == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check length
|
||||
if len(desc) > MaxDescriptionLength {
|
||||
return fmt.Errorf("description exceeds maximum length of %d characters", MaxDescriptionLength)
|
||||
}
|
||||
|
||||
// Check for valid UTF-8
|
||||
if !utf8.ValidString(desc) {
|
||||
return fmt.Errorf("description contains invalid characters")
|
||||
}
|
||||
|
||||
// Check for null bytes
|
||||
if strings.ContainsRune(desc, 0) {
|
||||
return fmt.Errorf("description contains null characters")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateOTT validates a one-time token (8-digit code)
|
||||
func ValidateOTT(ott string) error {
|
||||
if ott == "" {
|
||||
return fmt.Errorf("verification code is required")
|
||||
}
|
||||
|
||||
// Trim whitespace (users might copy-paste with spaces)
|
||||
ott = strings.TrimSpace(ott)
|
||||
|
||||
if !ottRegex.MatchString(ott) {
|
||||
return fmt.Errorf("verification code must be an 8-digit number")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatePassword validates a password
|
||||
func ValidatePassword(password string) error {
|
||||
if password == "" {
|
||||
return fmt.Errorf("password is required")
|
||||
}
|
||||
|
||||
if len(password) < MinPasswordLength {
|
||||
return fmt.Errorf("password must be at least %d characters", MinPasswordLength)
|
||||
}
|
||||
|
||||
if len(password) > MaxPasswordLength {
|
||||
return fmt.Errorf("password exceeds maximum length of %d characters", MaxPasswordLength)
|
||||
}
|
||||
|
||||
// Check for null bytes (could indicate injection attempt)
|
||||
if strings.ContainsRune(password, 0) {
|
||||
return fmt.Errorf("password contains invalid characters")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateCollectionID is a convenience function for collection ID validation
|
||||
func ValidateCollectionID(id string) error {
|
||||
return ValidateUUID(id, "collection ID")
|
||||
}
|
||||
|
||||
// ValidateFileID is a convenience function for file ID validation
|
||||
func ValidateFileID(id string) error {
|
||||
return ValidateUUID(id, "file ID")
|
||||
}
|
||||
|
||||
// ValidateTagID is a convenience function for tag ID validation
|
||||
func ValidateTagID(id string) error {
|
||||
return ValidateUUID(id, "tag ID")
|
||||
}
|
||||
|
||||
// ValidateCollectionName validates a collection name
|
||||
func ValidateCollectionName(name string) error {
|
||||
return ValidateName(name, "collection name")
|
||||
}
|
||||
|
||||
// ValidateFileName validates a file name
|
||||
func ValidateFileName(name string) error {
|
||||
if err := ValidateName(name, "filename"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Additional file-specific validations
|
||||
// Check for path traversal attempts
|
||||
if strings.Contains(name, "..") {
|
||||
return fmt.Errorf("filename cannot contain path traversal sequences")
|
||||
}
|
||||
|
||||
// Check for path separators
|
||||
if strings.ContainsAny(name, "/\\") {
|
||||
return fmt.Errorf("filename cannot contain path separators")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SanitizeString removes or replaces potentially dangerous characters
|
||||
// This is a defense-in-depth measure - validation should be done first
|
||||
func SanitizeString(s string) string {
|
||||
// Remove null bytes
|
||||
s = strings.ReplaceAll(s, "\x00", "")
|
||||
|
||||
// Trim excessive whitespace
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
return s
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
package inputvalidation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AllowedDownloadHosts lists the allowed hosts for presigned download URLs.
|
||||
// These are the only hosts from which the application will download files.
|
||||
var AllowedDownloadHosts = []string{
|
||||
// Production S3-compatible storage (Digital Ocean Spaces)
|
||||
".digitaloceanspaces.com",
|
||||
// AWS S3 (if used in future)
|
||||
".s3.amazonaws.com",
|
||||
".s3.us-east-1.amazonaws.com",
|
||||
".s3.us-west-2.amazonaws.com",
|
||||
".s3.eu-west-1.amazonaws.com",
|
||||
// MapleFile domains (if serving files directly)
|
||||
".maplefile.ca",
|
||||
// Local development
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
}
|
||||
|
||||
// ValidateDownloadURL validates a presigned download URL before use.
|
||||
// This prevents SSRF attacks by ensuring downloads only happen from trusted hosts.
|
||||
func ValidateDownloadURL(rawURL string) error {
|
||||
if rawURL == "" {
|
||||
return fmt.Errorf("download URL is required")
|
||||
}
|
||||
|
||||
// Parse the URL
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL format: %w", err)
|
||||
}
|
||||
|
||||
// Validate scheme - must be HTTPS (except localhost for development)
|
||||
if parsedURL.Scheme != "https" && parsedURL.Scheme != "http" {
|
||||
return fmt.Errorf("URL must use HTTP or HTTPS scheme")
|
||||
}
|
||||
|
||||
// Get host without port
|
||||
host := parsedURL.Hostname()
|
||||
if host == "" {
|
||||
return fmt.Errorf("URL must have a valid host")
|
||||
}
|
||||
|
||||
// For HTTPS requirement - only allow HTTP for localhost/local IPs
|
||||
if parsedURL.Scheme == "http" {
|
||||
if !isLocalHost(host) {
|
||||
return fmt.Errorf("non-local URLs must use HTTPS")
|
||||
}
|
||||
}
|
||||
|
||||
// Check if host is in allowed list
|
||||
if !isAllowedHost(host) {
|
||||
return fmt.Errorf("download from host %q is not allowed", host)
|
||||
}
|
||||
|
||||
// Check for credentials in URL (security risk)
|
||||
if parsedURL.User != nil {
|
||||
return fmt.Errorf("URL must not contain credentials")
|
||||
}
|
||||
|
||||
// Check for suspicious path traversal in URL path
|
||||
if strings.Contains(parsedURL.Path, "..") {
|
||||
return fmt.Errorf("URL path contains invalid sequences")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAllowedHost checks if a host is in the allowed download hosts list
|
||||
func isAllowedHost(host string) bool {
|
||||
host = strings.ToLower(host)
|
||||
|
||||
for _, allowed := range AllowedDownloadHosts {
|
||||
allowed = strings.ToLower(allowed)
|
||||
|
||||
// Exact match
|
||||
if host == allowed {
|
||||
return true
|
||||
}
|
||||
|
||||
// Suffix match for wildcard domains (e.g., ".digitaloceanspaces.com")
|
||||
if strings.HasPrefix(allowed, ".") && strings.HasSuffix(host, allowed) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle subdomains for non-wildcard entries
|
||||
if !strings.HasPrefix(allowed, ".") {
|
||||
if host == allowed || strings.HasSuffix(host, "."+allowed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isLocalHost checks if a host is localhost or a local IP address
|
||||
func isLocalHost(host string) bool {
|
||||
host = strings.ToLower(host)
|
||||
|
||||
// Check common localhost names
|
||||
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if it's a local network IP
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for loopback
|
||||
if ip.IsLoopback() {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for private network ranges (10.x.x.x, 192.168.x.x, 172.16-31.x.x)
|
||||
if ip.IsPrivate() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidateAPIBaseURL validates a base URL for API requests
|
||||
func ValidateAPIBaseURL(rawURL string) error {
|
||||
if rawURL == "" {
|
||||
return fmt.Errorf("API URL is required")
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL format: %w", err)
|
||||
}
|
||||
|
||||
// Validate scheme
|
||||
if parsedURL.Scheme != "https" && parsedURL.Scheme != "http" {
|
||||
return fmt.Errorf("URL must use HTTP or HTTPS scheme")
|
||||
}
|
||||
|
||||
// Get host
|
||||
host := parsedURL.Hostname()
|
||||
if host == "" {
|
||||
return fmt.Errorf("URL must have a valid host")
|
||||
}
|
||||
|
||||
// For HTTPS requirement - only allow HTTP for localhost/local IPs
|
||||
if parsedURL.Scheme == "http" {
|
||||
if !isLocalHost(host) {
|
||||
return fmt.Errorf("non-local URLs must use HTTPS")
|
||||
}
|
||||
}
|
||||
|
||||
// Check for credentials in URL
|
||||
if parsedURL.User != nil {
|
||||
return fmt.Errorf("URL must not contain credentials")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue