chore: git cache cleanup
This commit is contained in:
42
backend/internal/util/crypto.go
Normal file
42
backend/internal/util/crypto.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
)
|
||||
|
||||
// ConstantTimeCompare compares two strings in constant time to prevent comparison timing attacks.
|
||||
//
|
||||
// PROTECTION SCOPE:
|
||||
// This function protects against timing attacks during the comparison operation itself,
|
||||
// where an attacker might measure how long it takes to compare two strings byte-by-byte
|
||||
// to infer information about the expected value.
|
||||
//
|
||||
// IMPORTANT LIMITATIONS:
|
||||
// This does NOT protect against timing variance in database queries. If you retrieve a token
|
||||
// from the database (e.g., WHERE invite_token = ?), the DB query timing will vary based on
|
||||
// whether the token exists, potentially revealing information to an attacker through timing analysis.
|
||||
// See backend/internal/api/handlers/user_handler.go for examples of this limitation.
|
||||
//
|
||||
// DEFENSE-IN-DEPTH:
|
||||
// Despite this limitation, using constant-time comparison is still valuable as part of a
|
||||
// defense-in-depth strategy. It eliminates one potential timing leak and should be used
|
||||
// when comparing sensitive values like API keys, tokens, or passwords that are already
|
||||
// in memory.
|
||||
//
|
||||
// Returns true if the strings are equal, false otherwise.
|
||||
func ConstantTimeCompare(a, b string) bool {
|
||||
aBytes := []byte(a)
|
||||
bBytes := []byte(b)
|
||||
|
||||
// subtle.ConstantTimeCompare returns 1 if equal, 0 if not
|
||||
return subtle.ConstantTimeCompare(aBytes, bBytes) == 1
|
||||
}
|
||||
|
||||
// ConstantTimeCompareBytes compares two byte slices in constant time to prevent comparison timing attacks.
|
||||
//
|
||||
// This function has the same protection scope and limitations as ConstantTimeCompare.
|
||||
// See ConstantTimeCompare documentation for details on what this protects against and
|
||||
// what it does NOT protect against (e.g., database query timing variance).
|
||||
func ConstantTimeCompareBytes(a, b []byte) bool {
|
||||
return subtle.ConstantTimeCompare(a, b) == 1
|
||||
}
|
||||
85
backend/internal/util/crypto_test.go
Normal file
85
backend/internal/util/crypto_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConstantTimeCompare(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
a string
|
||||
b string
|
||||
expected bool
|
||||
}{
|
||||
{"equal strings", "secret123", "secret123", true},
|
||||
{"different strings", "secret123", "secret456", false},
|
||||
{"different lengths", "short", "muchlonger", false},
|
||||
{"empty strings", "", "", true},
|
||||
{"one empty", "notempty", "", false},
|
||||
{"unicode equal", "héllo", "héllo", true},
|
||||
{"unicode different", "héllo", "hëllo", false},
|
||||
{"special chars equal", "!@#$%^&*()", "!@#$%^&*()", true},
|
||||
{"whitespace matters", "hello ", "hello", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ConstantTimeCompare(tt.a, tt.b)
|
||||
if result != tt.expected {
|
||||
t.Errorf("ConstantTimeCompare(%q, %q) = %v, want %v", tt.a, tt.b, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstantTimeCompareBytes(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
a []byte
|
||||
b []byte
|
||||
expected bool
|
||||
}{
|
||||
{"equal bytes", []byte{1, 2, 3}, []byte{1, 2, 3}, true},
|
||||
{"different bytes", []byte{1, 2, 3}, []byte{1, 2, 4}, false},
|
||||
{"different lengths", []byte{1, 2}, []byte{1, 2, 3}, false},
|
||||
{"empty slices", []byte{}, []byte{}, true},
|
||||
{"nil slices", nil, nil, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ConstantTimeCompareBytes(tt.a, tt.b)
|
||||
if result != tt.expected {
|
||||
t.Errorf("ConstantTimeCompareBytes(%v, %v) = %v, want %v", tt.a, tt.b, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkConstantTimeCompare ensures the function remains constant-time.
|
||||
func BenchmarkConstantTimeCompare(b *testing.B) {
|
||||
// #nosec G101 -- Test fixture for benchmarking constant-time comparison, not a real credential
|
||||
secret := "a]3kL9#mP2$vN7@qR5*wX1&yT4^uI8%oE0!"
|
||||
|
||||
b.Run("equal", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
ConstantTimeCompare(secret, secret)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("different_first_char", func(b *testing.B) {
|
||||
different := "b]3kL9#mP2$vN7@qR5*wX1&yT4^uI8%oE0!"
|
||||
for i := 0; i < b.N; i++ {
|
||||
ConstantTimeCompare(secret, different)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("different_last_char", func(b *testing.B) {
|
||||
different := "a]3kL9#mP2$vN7@qR5*wX1&yT4^uI8%oE0?"
|
||||
for i := 0; i < b.N; i++ {
|
||||
ConstantTimeCompare(secret, different)
|
||||
}
|
||||
})
|
||||
}
|
||||
175
backend/internal/util/permissions.go
Normal file
175
backend/internal/util/permissions.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type PermissionCheck struct {
|
||||
Path string `json:"path"`
|
||||
Required string `json:"required"`
|
||||
Exists bool `json:"exists"`
|
||||
Writable bool `json:"writable"`
|
||||
OwnerUID int `json:"owner_uid"`
|
||||
OwnerGID int `json:"owner_gid"`
|
||||
Mode string `json:"mode"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
}
|
||||
|
||||
func CheckPathPermissions(path, required string) PermissionCheck {
|
||||
result := PermissionCheck{
|
||||
Path: path,
|
||||
Required: required,
|
||||
}
|
||||
|
||||
if strings.ContainsRune(path, '\x00') {
|
||||
result.Writable = false
|
||||
result.Error = "invalid path"
|
||||
result.ErrorCode = "permissions_invalid_path"
|
||||
return result
|
||||
}
|
||||
|
||||
cleanPath := filepath.Clean(path)
|
||||
|
||||
linkInfo, linkErr := os.Lstat(cleanPath)
|
||||
if linkErr != nil {
|
||||
result.Writable = false
|
||||
result.Error = linkErr.Error()
|
||||
result.ErrorCode = MapDiagnosticErrorCode(linkErr)
|
||||
return result
|
||||
}
|
||||
if linkInfo.Mode()&os.ModeSymlink != 0 {
|
||||
result.Writable = false
|
||||
result.Error = "symlink paths are not supported"
|
||||
result.ErrorCode = "permissions_unsupported_type"
|
||||
return result
|
||||
}
|
||||
|
||||
info, err := os.Stat(cleanPath)
|
||||
if err != nil {
|
||||
result.Writable = false
|
||||
result.Error = err.Error()
|
||||
result.ErrorCode = MapDiagnosticErrorCode(err)
|
||||
return result
|
||||
}
|
||||
|
||||
result.Exists = true
|
||||
|
||||
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||
result.OwnerUID = int(stat.Uid)
|
||||
result.OwnerGID = int(stat.Gid)
|
||||
}
|
||||
result.Mode = fmt.Sprintf("%04o", info.Mode().Perm())
|
||||
|
||||
if !info.IsDir() && !info.Mode().IsRegular() {
|
||||
result.Writable = false
|
||||
result.Error = "unsupported file type"
|
||||
result.ErrorCode = "permissions_unsupported_type"
|
||||
return result
|
||||
}
|
||||
|
||||
if strings.Contains(required, "w") {
|
||||
if info.IsDir() {
|
||||
probeFile, probeErr := os.CreateTemp(cleanPath, "permcheck-*")
|
||||
if probeErr != nil {
|
||||
result.Writable = false
|
||||
result.Error = probeErr.Error()
|
||||
result.ErrorCode = MapDiagnosticErrorCode(probeErr)
|
||||
return result
|
||||
}
|
||||
if closeErr := probeFile.Close(); closeErr != nil {
|
||||
result.Writable = false
|
||||
result.Error = closeErr.Error()
|
||||
result.ErrorCode = MapDiagnosticErrorCode(closeErr)
|
||||
return result
|
||||
}
|
||||
if removeErr := os.Remove(probeFile.Name()); removeErr != nil {
|
||||
result.Writable = false
|
||||
result.Error = removeErr.Error()
|
||||
result.ErrorCode = MapDiagnosticErrorCode(removeErr)
|
||||
return result
|
||||
}
|
||||
result.Writable = true
|
||||
return result
|
||||
}
|
||||
|
||||
file, openErr := os.OpenFile(cleanPath, os.O_WRONLY, 0) // #nosec G304 -- cleanPath is normalized, existence-checked, non-symlink, and regular-file validated above.
|
||||
if openErr != nil {
|
||||
result.Writable = false
|
||||
result.Error = openErr.Error()
|
||||
result.ErrorCode = MapDiagnosticErrorCode(openErr)
|
||||
return result
|
||||
}
|
||||
if closeErr := file.Close(); closeErr != nil {
|
||||
result.Writable = false
|
||||
result.Error = closeErr.Error()
|
||||
result.ErrorCode = MapDiagnosticErrorCode(closeErr)
|
||||
return result
|
||||
}
|
||||
result.Writable = true
|
||||
return result
|
||||
}
|
||||
|
||||
result.Writable = false
|
||||
return result
|
||||
}
|
||||
|
||||
func MapDiagnosticErrorCode(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
return ""
|
||||
case os.IsNotExist(err):
|
||||
return "permissions_missing_path"
|
||||
case errors.Is(err, syscall.EROFS):
|
||||
return "permissions_readonly"
|
||||
case errors.Is(err, syscall.EACCES) || os.IsPermission(err):
|
||||
return "permissions_write_denied"
|
||||
default:
|
||||
return "permissions_write_failed"
|
||||
}
|
||||
}
|
||||
|
||||
func MapSaveErrorCode(err error) (string, bool) {
|
||||
switch {
|
||||
case err == nil:
|
||||
return "", false
|
||||
case IsSQLiteReadOnlyError(err):
|
||||
return "permissions_db_readonly", true
|
||||
case IsSQLiteLockedError(err):
|
||||
return "permissions_db_locked", true
|
||||
case errors.Is(err, syscall.EROFS):
|
||||
return "permissions_readonly", true
|
||||
case errors.Is(err, syscall.EACCES) || os.IsPermission(err):
|
||||
return "permissions_write_denied", true
|
||||
case strings.Contains(strings.ToLower(err.Error()), "permission denied"):
|
||||
return "permissions_write_denied", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func IsSQLiteReadOnlyError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "readonly") ||
|
||||
strings.Contains(msg, "read-only") ||
|
||||
strings.Contains(msg, "attempt to write a readonly database") ||
|
||||
strings.Contains(msg, "sqlite_readonly")
|
||||
}
|
||||
|
||||
func IsSQLiteLockedError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "database is locked") ||
|
||||
strings.Contains(msg, "sqlite_busy") ||
|
||||
strings.Contains(msg, "database locked")
|
||||
}
|
||||
236
backend/internal/util/permissions_test.go
Normal file
236
backend/internal/util/permissions_test.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMapSaveErrorCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantCode string
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "sqlite readonly",
|
||||
err: errors.New("attempt to write a readonly database"),
|
||||
wantCode: "permissions_db_readonly",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "sqlite locked",
|
||||
err: errors.New("database is locked"),
|
||||
wantCode: "permissions_db_locked",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "permission denied",
|
||||
err: fmt.Errorf("write failed: %w", syscall.EACCES),
|
||||
wantCode: "permissions_write_denied",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "not a permission error",
|
||||
err: errors.New("other error"),
|
||||
wantCode: "",
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
code, ok := MapSaveErrorCode(tt.err)
|
||||
if code != tt.wantCode || ok != tt.wantOK {
|
||||
t.Fatalf("MapSaveErrorCode() = (%q, %v), want (%q, %v)", code, ok, tt.wantCode, tt.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSQLiteReadOnlyError(t *testing.T) {
|
||||
if !IsSQLiteReadOnlyError(errors.New("SQLITE_READONLY")) {
|
||||
t.Fatalf("expected SQLITE_READONLY to be detected")
|
||||
}
|
||||
|
||||
if !IsSQLiteReadOnlyError(errors.New("read-only database")) {
|
||||
t.Fatalf("expected read-only variant to be detected")
|
||||
}
|
||||
|
||||
if IsSQLiteReadOnlyError(nil) {
|
||||
t.Fatalf("expected nil error to return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSQLiteLockedError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{name: "nil", err: nil, want: false},
|
||||
{name: "sqlite_busy", err: errors.New("SQLITE_BUSY"), want: true},
|
||||
{name: "database locked", err: errors.New("database locked by transaction"), want: true},
|
||||
{name: "other", err: errors.New("some other failure"), want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsSQLiteLockedError(tt.err); got != tt.want {
|
||||
t.Fatalf("IsSQLiteLockedError() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapDiagnosticErrorCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{name: "nil", err: nil, want: ""},
|
||||
{name: "not found", err: os.ErrNotExist, want: "permissions_missing_path"},
|
||||
{name: "readonly", err: syscall.EROFS, want: "permissions_readonly"},
|
||||
{name: "permission denied", err: syscall.EACCES, want: "permissions_write_denied"},
|
||||
{name: "other", err: errors.New("boom"), want: "permissions_write_failed"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := MapDiagnosticErrorCode(tt.err); got != tt.want {
|
||||
t.Fatalf("MapDiagnosticErrorCode() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPathPermissions(t *testing.T) {
|
||||
t.Run("missing path", func(t *testing.T) {
|
||||
result := CheckPathPermissions("/definitely/missing/path", "rw")
|
||||
if result.Exists {
|
||||
t.Fatalf("expected missing path to not exist")
|
||||
}
|
||||
if result.ErrorCode != "permissions_missing_path" {
|
||||
t.Fatalf("expected permissions_missing_path, got %q", result.ErrorCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("writable file", func(t *testing.T) {
|
||||
tempFile, err := os.CreateTemp(t.TempDir(), "perm-file-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp file: %v", err)
|
||||
}
|
||||
if closeErr := tempFile.Close(); closeErr != nil {
|
||||
t.Fatalf("close temp file: %v", closeErr)
|
||||
}
|
||||
|
||||
result := CheckPathPermissions(tempFile.Name(), "rw")
|
||||
if !result.Exists {
|
||||
t.Fatalf("expected file to exist")
|
||||
}
|
||||
if !result.Writable {
|
||||
t.Fatalf("expected file to be writable, got error: %s", result.Error)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("writable directory", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
result := CheckPathPermissions(dir, "rwx")
|
||||
if !result.Exists {
|
||||
t.Fatalf("expected directory to exist")
|
||||
}
|
||||
if !result.Writable {
|
||||
t.Fatalf("expected directory to be writable, got error: %s", result.Error)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no write required", func(t *testing.T) {
|
||||
tempFile, err := os.CreateTemp(t.TempDir(), "perm-read-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp file: %v", err)
|
||||
}
|
||||
if closeErr := tempFile.Close(); closeErr != nil {
|
||||
t.Fatalf("close temp file: %v", closeErr)
|
||||
}
|
||||
|
||||
result := CheckPathPermissions(tempFile.Name(), "r")
|
||||
if result.Writable {
|
||||
t.Fatalf("expected writable=false when write permission is not required")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unsupported file type", func(t *testing.T) {
|
||||
fifoPath := filepath.Join(t.TempDir(), "perm-fifo")
|
||||
if err := syscall.Mkfifo(fifoPath, 0o600); err != nil {
|
||||
t.Fatalf("create fifo: %v", err)
|
||||
}
|
||||
|
||||
result := CheckPathPermissions(fifoPath, "rw")
|
||||
if result.ErrorCode != "permissions_unsupported_type" {
|
||||
t.Fatalf("expected permissions_unsupported_type, got %q", result.ErrorCode)
|
||||
}
|
||||
if result.Writable {
|
||||
t.Fatalf("expected writable=false for unsupported file type")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMapSaveErrorCode_PermissionDeniedText(t *testing.T) {
|
||||
code, ok := MapSaveErrorCode(errors.New("Write failed: Permission Denied"))
|
||||
if !ok {
|
||||
t.Fatalf("expected permission denied text to be recognized")
|
||||
}
|
||||
if code != "permissions_write_denied" {
|
||||
t.Fatalf("expected permissions_write_denied, got %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPathPermissions_NullBytePath(t *testing.T) {
|
||||
result := CheckPathPermissions("bad\x00path", "rw")
|
||||
if result.ErrorCode != "permissions_invalid_path" {
|
||||
t.Fatalf("expected permissions_invalid_path, got %q", result.ErrorCode)
|
||||
}
|
||||
if result.Writable {
|
||||
t.Fatalf("expected writable=false for null-byte path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPathPermissions_SymlinkPath(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink test is environment-dependent on windows")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
target := filepath.Join(tmpDir, "target.txt")
|
||||
if err := os.WriteFile(target, []byte("ok"), 0o600); err != nil {
|
||||
t.Fatalf("write target: %v", err)
|
||||
}
|
||||
link := filepath.Join(tmpDir, "target-link.txt")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("symlink not available in this environment: %v", err)
|
||||
}
|
||||
|
||||
result := CheckPathPermissions(link, "rw")
|
||||
if result.ErrorCode != "permissions_unsupported_type" {
|
||||
t.Fatalf("expected permissions_unsupported_type, got %q", result.ErrorCode)
|
||||
}
|
||||
if result.Writable {
|
||||
t.Fatalf("expected writable=false for symlink path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapSaveErrorCode_ReadOnlyFilesystem(t *testing.T) {
|
||||
code, ok := MapSaveErrorCode(syscall.EROFS)
|
||||
if !ok {
|
||||
t.Fatalf("expected readonly filesystem to be recognized")
|
||||
}
|
||||
if code != "permissions_db_readonly" {
|
||||
t.Fatalf("expected permissions_db_readonly, got %q", code)
|
||||
}
|
||||
}
|
||||
57
backend/internal/util/sanitize.go
Normal file
57
backend/internal/util/sanitize.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Package util provides utility functions used across the application.
|
||||
package util
|
||||
|
||||
import (
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SanitizeForLog removes control characters and newlines from user content before logging.
|
||||
func SanitizeForLog(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
s = strings.ReplaceAll(s, "\r\n", " ")
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
re := regexp.MustCompile(`[\x00-\x1F\x7F]+`)
|
||||
s = re.ReplaceAllString(s, " ")
|
||||
return s
|
||||
}
|
||||
|
||||
// CanonicalizeIPForSecurity normalizes an IP string for security decisions
|
||||
// (rate limiting keys, allow-list CIDR checks, etc.). It preserves Gin's
|
||||
// trust proxy behavior by operating on the already-resolved client IP string.
|
||||
//
|
||||
// Normalizations:
|
||||
// - IPv6 loopback (::1) -> 127.0.0.1 (stable across IPv4/IPv6 localhost)
|
||||
// - IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1) -> 127.0.0.1
|
||||
func CanonicalizeIPForSecurity(ipStr string) string {
|
||||
ipStr = strings.TrimSpace(ipStr)
|
||||
if ipStr == "" {
|
||||
return ipStr
|
||||
}
|
||||
|
||||
// Defensive normalization in case the input is not a plain IP string.
|
||||
// Gin's Context.ClientIP() should return an IP, but in proxy/test setups
|
||||
// we may still see host:port or comma-separated values.
|
||||
if idx := strings.IndexByte(ipStr, ','); idx >= 0 {
|
||||
ipStr = strings.TrimSpace(ipStr[:idx])
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(ipStr); err == nil {
|
||||
ipStr = host
|
||||
}
|
||||
ipStr = strings.Trim(ipStr, "[]")
|
||||
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return ipStr
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
return v4.String()
|
||||
}
|
||||
if ip.IsLoopback() {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
return ip.String()
|
||||
}
|
||||
171
backend/internal/util/sanitize_test.go
Normal file
171
backend/internal/util/sanitize_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package util
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSanitizeForLog(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "clean string",
|
||||
input: "Hello World",
|
||||
expected: "Hello World",
|
||||
},
|
||||
{
|
||||
name: "string with newline",
|
||||
input: "Hello\nWorld",
|
||||
expected: "Hello World",
|
||||
},
|
||||
{
|
||||
name: "string with carriage return and newline",
|
||||
input: "Hello\r\nWorld",
|
||||
expected: "Hello World",
|
||||
},
|
||||
{
|
||||
name: "string with multiple newlines",
|
||||
input: "Hello\nWorld\nTest",
|
||||
expected: "Hello World Test",
|
||||
},
|
||||
{
|
||||
name: "string with control characters",
|
||||
input: "Hello\x00\x01\x1FWorld",
|
||||
expected: "Hello World",
|
||||
},
|
||||
{
|
||||
name: "string with DEL character (0x7F)",
|
||||
input: "Hello\x7FWorld",
|
||||
expected: "Hello World",
|
||||
},
|
||||
{
|
||||
name: "complex string with mixed control chars",
|
||||
input: "Line1\r\nLine2\nLine3\x00\x01\x7F",
|
||||
expected: "Line1 Line2 Line3 ",
|
||||
},
|
||||
{
|
||||
name: "string with tabs (0x09 is control char)",
|
||||
input: "Hello\tWorld",
|
||||
expected: "Hello World",
|
||||
},
|
||||
{
|
||||
name: "string with only control chars",
|
||||
input: "\x00\x01\x02\x1F\x7F",
|
||||
expected: " ",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := SanitizeForLog(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("SanitizeForLog(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalizeIPForSecurity(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "IPv4 standard",
|
||||
input: "192.168.1.1",
|
||||
expected: "192.168.1.1",
|
||||
},
|
||||
{
|
||||
name: "IPv4 with port (should strip port)",
|
||||
input: "192.168.1.1:8080",
|
||||
expected: "192.168.1.1",
|
||||
},
|
||||
{
|
||||
name: "IPv6 standard",
|
||||
input: "2001:db8::1",
|
||||
expected: "2001:db8::1",
|
||||
},
|
||||
{
|
||||
name: "IPv6 loopback (::1) normalizes to 127.0.0.1",
|
||||
input: "::1",
|
||||
expected: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "IPv6 loopback with brackets",
|
||||
input: "[::1]",
|
||||
expected: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "IPv6 loopback with brackets and port",
|
||||
input: "[::1]:8080",
|
||||
expected: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "IPv4-mapped IPv6 address",
|
||||
input: "::ffff:192.168.1.1",
|
||||
expected: "192.168.1.1",
|
||||
},
|
||||
{
|
||||
name: "IPv4-mapped IPv6 with brackets",
|
||||
input: "[::ffff:192.168.1.1]",
|
||||
expected: "192.168.1.1",
|
||||
},
|
||||
{
|
||||
name: "IPv4 localhost",
|
||||
input: "127.0.0.1",
|
||||
expected: "127.0.0.1",
|
||||
},
|
||||
{
|
||||
name: "IPv4 0.0.0.0",
|
||||
input: "0.0.0.0",
|
||||
expected: "0.0.0.0",
|
||||
},
|
||||
{
|
||||
name: "invalid IP format",
|
||||
input: "invalid",
|
||||
expected: "invalid",
|
||||
},
|
||||
{
|
||||
name: "comma-separated (should take first)",
|
||||
input: "192.168.1.1, 10.0.0.1",
|
||||
expected: "192.168.1.1",
|
||||
},
|
||||
{
|
||||
name: "whitespace",
|
||||
input: " 192.168.1.1 ",
|
||||
expected: "192.168.1.1",
|
||||
},
|
||||
{
|
||||
name: "IPv6 full form",
|
||||
input: "2001:0db8:0000:0000:0000:0000:0000:0001",
|
||||
expected: "2001:db8::1",
|
||||
},
|
||||
{
|
||||
name: "IPv6 with zone",
|
||||
input: "fe80::1%eth0",
|
||||
expected: "fe80::1%eth0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := CanonicalizeIPForSecurity(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("CanonicalizeIPForSecurity(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user