chore: git cache cleanup

This commit is contained in:
GitHub Actions
2026-03-04 18:34:49 +00:00
parent c32cce2a88
commit 27c252600a
2001 changed files with 683185 additions and 0 deletions

View 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
}

View 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)
}
})
}

View 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")
}

View 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)
}
}

View 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()
}

View 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)
}
})
}
}