Compare commits
2
Commits
f7d43def1c
...
e2c46c9e58
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2c46c9e58 | ||
|
|
96958af310 |
@@ -1,268 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SecurityLogger struct {
|
|
||||||
logger *log.Logger
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSecurityLogger() *SecurityLogger {
|
|
||||||
return &SecurityLogger{
|
|
||||||
logger: log.New(os.Stdout, "[SECURITY] ", log.LstdFlags|log.Lshortfile),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type SecurityEvent struct {
|
|
||||||
Type string
|
|
||||||
IP string
|
|
||||||
UserAgent string
|
|
||||||
Path string
|
|
||||||
Method string
|
|
||||||
UserID uint
|
|
||||||
Details string
|
|
||||||
Timestamp time.Time
|
|
||||||
Severity string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sl *SecurityLogger) LogSecurityEvent(event SecurityEvent) {
|
|
||||||
sl.logger.Printf("[%s] %s - %s %s %s - UserID: %d - %s - %s",
|
|
||||||
event.Severity,
|
|
||||||
event.IP,
|
|
||||||
event.Method,
|
|
||||||
event.Path,
|
|
||||||
event.UserAgent,
|
|
||||||
event.UserID,
|
|
||||||
event.Type,
|
|
||||||
event.Details,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func SecurityLoggingMiddleware(logger *SecurityLogger) func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
start := time.Now()
|
|
||||||
|
|
||||||
rw := &securityResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
|
||||||
|
|
||||||
next.ServeHTTP(rw, r)
|
|
||||||
|
|
||||||
userUID := uint(0)
|
|
||||||
if u := GetUserIDFromContext(r.Context()); u != nil {
|
|
||||||
userUID = *u
|
|
||||||
}
|
|
||||||
ip := getClientIP(r)
|
|
||||||
|
|
||||||
event := SecurityEvent{
|
|
||||||
IP: ip,
|
|
||||||
UserAgent: r.UserAgent(),
|
|
||||||
Path: r.URL.Path,
|
|
||||||
Method: r.Method,
|
|
||||||
UserID: userUID,
|
|
||||||
Timestamp: start,
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case rw.statusCode >= 400 && rw.statusCode < 500:
|
|
||||||
event.Type = "Client Error"
|
|
||||||
event.Severity = "WARN"
|
|
||||||
event.Details = "Client error response"
|
|
||||||
case rw.statusCode >= 500:
|
|
||||||
event.Type = "Server Error"
|
|
||||||
event.Severity = "ERROR"
|
|
||||||
event.Details = "Server error response"
|
|
||||||
case strings.HasPrefix(r.URL.Path, "/api/auth/"):
|
|
||||||
event.Type = "Authentication"
|
|
||||||
event.Severity = "INFO"
|
|
||||||
event.Details = "Authentication endpoint accessed"
|
|
||||||
case strings.HasPrefix(r.URL.Path, "/api/posts/") && r.Method == "POST":
|
|
||||||
event.Type = "Post Creation"
|
|
||||||
event.Severity = "INFO"
|
|
||||||
event.Details = "Post creation attempt"
|
|
||||||
case strings.HasPrefix(r.URL.Path, "/api/posts/") && (r.Method == "PUT" || r.Method == "DELETE"):
|
|
||||||
event.Type = "Post Modification"
|
|
||||||
event.Severity = "INFO"
|
|
||||||
event.Details = "Post modification attempt"
|
|
||||||
default:
|
|
||||||
event.Type = "API Access"
|
|
||||||
event.Severity = "INFO"
|
|
||||||
event.Details = "API endpoint accessed"
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogSecurityEvent(event)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func SuspiciousActivityMiddleware(logger *SecurityLogger) func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ip := getClientIP(r)
|
|
||||||
userAgent := r.UserAgent()
|
|
||||||
|
|
||||||
suspicious := false
|
|
||||||
details := ""
|
|
||||||
|
|
||||||
pathProbe := layeredUnescape(r.URL.Path, url.PathUnescape)
|
|
||||||
queryProbe := layeredUnescape(r.URL.RawQuery, url.QueryUnescape)
|
|
||||||
|
|
||||||
if containsSQLInjection(pathProbe) || containsSQLInjection(queryProbe) {
|
|
||||||
suspicious = true
|
|
||||||
details = "Potential SQL injection attempt"
|
|
||||||
}
|
|
||||||
|
|
||||||
if containsXSS(pathProbe) || containsXSS(queryProbe) {
|
|
||||||
suspicious = true
|
|
||||||
details = "Potential XSS attempt"
|
|
||||||
}
|
|
||||||
|
|
||||||
if isSuspiciousUserAgent(userAgent) {
|
|
||||||
suspicious = true
|
|
||||||
details = "Suspicious user agent"
|
|
||||||
}
|
|
||||||
|
|
||||||
if isRapidRequest(ip) {
|
|
||||||
suspicious = true
|
|
||||||
details = "Rapid request pattern"
|
|
||||||
}
|
|
||||||
|
|
||||||
if suspicious {
|
|
||||||
event := SecurityEvent{
|
|
||||||
Type: "Suspicious Activity",
|
|
||||||
IP: ip,
|
|
||||||
UserAgent: userAgent,
|
|
||||||
Path: r.URL.Path,
|
|
||||||
Method: r.Method,
|
|
||||||
Details: details,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Severity: "WARN",
|
|
||||||
}
|
|
||||||
logger.LogSecurityEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type securityResponseWriter struct {
|
|
||||||
http.ResponseWriter
|
|
||||||
statusCode int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rw *securityResponseWriter) WriteHeader(code int) {
|
|
||||||
rw.statusCode = code
|
|
||||||
rw.ResponseWriter.WriteHeader(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getClientIP(r *http.Request) string {
|
|
||||||
return GetSecureClientIP(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
func layeredUnescape(s string, decoder func(string) (string, error)) string {
|
|
||||||
out := s
|
|
||||||
for range 3 {
|
|
||||||
d, err := decoder(out)
|
|
||||||
if err != nil || d == out {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
out = d
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsSQLInjection(input string) bool {
|
|
||||||
sqlPatterns := []string{
|
|
||||||
"' OR '1'='1",
|
|
||||||
"'; DROP TABLE",
|
|
||||||
"UNION SELECT",
|
|
||||||
"INSERT INTO",
|
|
||||||
"DELETE FROM",
|
|
||||||
"UPDATE SET",
|
|
||||||
}
|
|
||||||
|
|
||||||
input = strings.ToUpper(input)
|
|
||||||
for _, pattern := range sqlPatterns {
|
|
||||||
if strings.Contains(input, strings.ToUpper(pattern)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsXSS(input string) bool {
|
|
||||||
xssPatterns := []string{
|
|
||||||
"<script>",
|
|
||||||
"javascript:",
|
|
||||||
"onload=",
|
|
||||||
"onerror=",
|
|
||||||
"onclick=",
|
|
||||||
"<iframe>",
|
|
||||||
"<img src=",
|
|
||||||
}
|
|
||||||
|
|
||||||
input = strings.ToLower(input)
|
|
||||||
for _, pattern := range xssPatterns {
|
|
||||||
if strings.Contains(input, pattern) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func isSuspiciousUserAgent(userAgent string) bool {
|
|
||||||
suspiciousPatterns := []string{
|
|
||||||
"sqlmap",
|
|
||||||
"nikto",
|
|
||||||
"nmap",
|
|
||||||
"masscan",
|
|
||||||
"zap",
|
|
||||||
"burp",
|
|
||||||
"w3af",
|
|
||||||
"havij",
|
|
||||||
"acunetix",
|
|
||||||
"nessus",
|
|
||||||
}
|
|
||||||
|
|
||||||
userAgent = strings.ToLower(userAgent)
|
|
||||||
for _, pattern := range suspiciousPatterns {
|
|
||||||
if strings.Contains(userAgent, pattern) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
type rapidRequestTracker struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
counts map[string]int
|
|
||||||
lastReset time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
var rapidRequests = rapidRequestTracker{
|
|
||||||
counts: make(map[string]int),
|
|
||||||
lastReset: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
func isRapidRequest(ip string) bool {
|
|
||||||
rapidRequests.mu.Lock()
|
|
||||||
defer rapidRequests.mu.Unlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
if now.Sub(rapidRequests.lastReset) > time.Minute {
|
|
||||||
rapidRequests.counts = make(map[string]int)
|
|
||||||
rapidRequests.lastReset = now
|
|
||||||
}
|
|
||||||
|
|
||||||
rapidRequests.counts[ip]++
|
|
||||||
|
|
||||||
return rapidRequests.counts[ip] > 100
|
|
||||||
}
|
|
||||||
@@ -1,625 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewSecurityLogger(t *testing.T) {
|
|
||||||
logger := NewSecurityLogger()
|
|
||||||
if logger == nil {
|
|
||||||
t.Fatal("NewSecurityLogger should not return nil")
|
|
||||||
}
|
|
||||||
if logger.logger == nil {
|
|
||||||
t.Fatal("SecurityLogger should have a logger instance")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSecurityLogger_LogSecurityEvent(t *testing.T) {
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
logger := &SecurityLogger{
|
|
||||||
logger: log.New(&buf, "[SECURITY] ", log.LstdFlags|log.Lshortfile),
|
|
||||||
}
|
|
||||||
|
|
||||||
event := SecurityEvent{
|
|
||||||
Type: "Test Event",
|
|
||||||
IP: "192.168.1.1",
|
|
||||||
UserAgent: "Test Agent",
|
|
||||||
Path: "/test",
|
|
||||||
Method: "GET",
|
|
||||||
UserID: 123,
|
|
||||||
Details: "Test details",
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Severity: "INFO",
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogSecurityEvent(event)
|
|
||||||
|
|
||||||
logOutput := buf.String()
|
|
||||||
expectedParts := []string{
|
|
||||||
"[INFO]",
|
|
||||||
"192.168.1.1",
|
|
||||||
"GET",
|
|
||||||
"/test",
|
|
||||||
"Test Agent",
|
|
||||||
"UserID: 123",
|
|
||||||
"Test Event",
|
|
||||||
"Test details",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, part := range expectedParts {
|
|
||||||
if !strings.Contains(logOutput, part) {
|
|
||||||
t.Errorf("Expected log output to contain %q, got %q", part, logOutput)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSecurityLoggingMiddleware_ClientError(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
logger := &SecurityLogger{
|
|
||||||
logger: log.New(&buf, "[SECURITY] ", log.LstdFlags|log.Lshortfile),
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := SecurityLoggingMiddleware(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
}))
|
|
||||||
|
|
||||||
request := httptest.NewRequest("GET", "/test", nil)
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler.ServeHTTP(recorder, request)
|
|
||||||
|
|
||||||
logOutput := buf.String()
|
|
||||||
expectedParts := []string{
|
|
||||||
"[WARN]",
|
|
||||||
"Client Error",
|
|
||||||
"Client error response",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, part := range expectedParts {
|
|
||||||
if !strings.Contains(logOutput, part) {
|
|
||||||
t.Errorf("Expected log output to contain %q, got %q", part, logOutput)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSecurityLoggingMiddleware_ServerError(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
logger := &SecurityLogger{
|
|
||||||
logger: log.New(&buf, "[SECURITY] ", log.LstdFlags|log.Lshortfile),
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := SecurityLoggingMiddleware(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
}))
|
|
||||||
|
|
||||||
request := httptest.NewRequest("GET", "/test", nil)
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler.ServeHTTP(recorder, request)
|
|
||||||
|
|
||||||
logOutput := buf.String()
|
|
||||||
expectedParts := []string{
|
|
||||||
"[ERROR]",
|
|
||||||
"Server Error",
|
|
||||||
"Server error response",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, part := range expectedParts {
|
|
||||||
if !strings.Contains(logOutput, part) {
|
|
||||||
t.Errorf("Expected log output to contain %q, got %q", part, logOutput)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSecurityLoggingMiddleware_Authentication(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
logger := &SecurityLogger{
|
|
||||||
logger: log.New(&buf, "[SECURITY] ", log.LstdFlags|log.Lshortfile),
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := SecurityLoggingMiddleware(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
|
|
||||||
request := httptest.NewRequest("POST", "/api/auth/login", nil)
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler.ServeHTTP(recorder, request)
|
|
||||||
|
|
||||||
logOutput := buf.String()
|
|
||||||
expectedParts := []string{
|
|
||||||
"[INFO]",
|
|
||||||
"Authentication",
|
|
||||||
"Authentication endpoint accessed",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, part := range expectedParts {
|
|
||||||
if !strings.Contains(logOutput, part) {
|
|
||||||
t.Errorf("Expected log output to contain %q, got %q", part, logOutput)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSecurityLoggingMiddleware_PostCreation(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
logger := &SecurityLogger{
|
|
||||||
logger: log.New(&buf, "[SECURITY] ", log.LstdFlags|log.Lshortfile),
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := SecurityLoggingMiddleware(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
|
|
||||||
request := httptest.NewRequest("POST", "/api/posts/", nil)
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler.ServeHTTP(recorder, request)
|
|
||||||
|
|
||||||
logOutput := buf.String()
|
|
||||||
expectedParts := []string{
|
|
||||||
"[INFO]",
|
|
||||||
"Post Creation",
|
|
||||||
"Post creation attempt",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, part := range expectedParts {
|
|
||||||
if !strings.Contains(logOutput, part) {
|
|
||||||
t.Errorf("Expected log output to contain %q, got %q", part, logOutput)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSecurityLoggingMiddleware_PostModification(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
logger := &SecurityLogger{
|
|
||||||
logger: log.New(&buf, "[SECURITY] ", log.LstdFlags|log.Lshortfile),
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := SecurityLoggingMiddleware(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
|
|
||||||
request := httptest.NewRequest("PUT", "/api/posts/1", nil)
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(recorder, request)
|
|
||||||
|
|
||||||
logOutput := buf.String()
|
|
||||||
expectedParts := []string{
|
|
||||||
"[INFO]",
|
|
||||||
"Post Modification",
|
|
||||||
"Post modification attempt",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, part := range expectedParts {
|
|
||||||
if !strings.Contains(logOutput, part) {
|
|
||||||
t.Errorf("Expected log output to contain %q, got %q", part, logOutput)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buf.Reset()
|
|
||||||
|
|
||||||
request = httptest.NewRequest("DELETE", "/api/posts/1", nil)
|
|
||||||
recorder = httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(recorder, request)
|
|
||||||
|
|
||||||
logOutput = buf.String()
|
|
||||||
for _, part := range expectedParts {
|
|
||||||
if !strings.Contains(logOutput, part) {
|
|
||||||
t.Errorf("Expected log output to contain %q, got %q", part, logOutput)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSecurityLoggingMiddleware_APIAccess(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
logger := &SecurityLogger{
|
|
||||||
logger: log.New(&buf, "[SECURITY] ", log.LstdFlags|log.Lshortfile),
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := SecurityLoggingMiddleware(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
|
|
||||||
request := httptest.NewRequest("GET", "/api/users", nil)
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler.ServeHTTP(recorder, request)
|
|
||||||
|
|
||||||
logOutput := buf.String()
|
|
||||||
expectedParts := []string{
|
|
||||||
"[INFO]",
|
|
||||||
"API Access",
|
|
||||||
"API endpoint accessed",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, part := range expectedParts {
|
|
||||||
if !strings.Contains(logOutput, part) {
|
|
||||||
t.Errorf("Expected log output to contain %q, got %q", part, logOutput)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSecurityLoggingMiddleware_WithUserID(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
logger := &SecurityLogger{
|
|
||||||
logger: log.New(&buf, "[SECURITY] ", log.LstdFlags|log.Lshortfile),
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := SecurityLoggingMiddleware(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
|
|
||||||
request := httptest.NewRequest("GET", "/test", nil)
|
|
||||||
request = request.WithContext(context.WithValue(request.Context(), UserIDKey, uint(456)))
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler.ServeHTTP(recorder, request)
|
|
||||||
|
|
||||||
logOutput := buf.String()
|
|
||||||
if !strings.Contains(logOutput, "UserID: 456") {
|
|
||||||
t.Errorf("Expected log output to contain UserID: 456, got %q", logOutput)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetClientIP(t *testing.T) {
|
|
||||||
|
|
||||||
originalTrust := TrustProxyHeaders
|
|
||||||
defer func() {
|
|
||||||
TrustProxyHeaders = originalTrust
|
|
||||||
}()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
headers map[string]string
|
|
||||||
remoteAddr string
|
|
||||||
trustProxyHeaders bool
|
|
||||||
expectedIP string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "Default: RemoteAddr when TrustProxyHeaders is false",
|
|
||||||
headers: map[string]string{"X-Forwarded-For": "192.168.1.100"},
|
|
||||||
remoteAddr: "10.0.0.1:8080",
|
|
||||||
trustProxyHeaders: false,
|
|
||||||
expectedIP: "10.0.0.1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "X-Forwarded-For single IP when TrustProxyHeaders is true",
|
|
||||||
headers: map[string]string{
|
|
||||||
"X-Forwarded-For": "192.168.1.100",
|
|
||||||
},
|
|
||||||
remoteAddr: "10.0.0.1:8080",
|
|
||||||
trustProxyHeaders: true,
|
|
||||||
expectedIP: "192.168.1.100",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "X-Forwarded-For multiple IPs when TrustProxyHeaders is true",
|
|
||||||
headers: map[string]string{
|
|
||||||
"X-Forwarded-For": "192.168.1.100, 10.0.0.1, 172.16.0.1",
|
|
||||||
},
|
|
||||||
remoteAddr: "10.0.0.1:8080",
|
|
||||||
trustProxyHeaders: true,
|
|
||||||
expectedIP: "192.168.1.100",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "X-Real-IP when TrustProxyHeaders is true",
|
|
||||||
headers: map[string]string{
|
|
||||||
"X-Real-IP": "192.168.1.200",
|
|
||||||
},
|
|
||||||
remoteAddr: "10.0.0.1:8080",
|
|
||||||
trustProxyHeaders: true,
|
|
||||||
expectedIP: "192.168.1.200",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "X-Forwarded-For takes precedence over X-Real-IP when TrustProxyHeaders is true",
|
|
||||||
headers: map[string]string{
|
|
||||||
"X-Forwarded-For": "192.168.1.100",
|
|
||||||
"X-Real-IP": "192.168.1.200",
|
|
||||||
},
|
|
||||||
remoteAddr: "10.0.0.1:8080",
|
|
||||||
trustProxyHeaders: true,
|
|
||||||
expectedIP: "192.168.1.100",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "RemoteAddr only",
|
|
||||||
headers: map[string]string{},
|
|
||||||
remoteAddr: "192.168.1.50:8080",
|
|
||||||
trustProxyHeaders: false,
|
|
||||||
expectedIP: "192.168.1.50",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "RemoteAddr with IPv6",
|
|
||||||
headers: map[string]string{},
|
|
||||||
remoteAddr: "[::1]:8080",
|
|
||||||
trustProxyHeaders: false,
|
|
||||||
expectedIP: "::1",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
TrustProxyHeaders = tt.trustProxyHeaders
|
|
||||||
request := httptest.NewRequest("GET", "/test", nil)
|
|
||||||
request.RemoteAddr = tt.remoteAddr
|
|
||||||
for header, value := range tt.headers {
|
|
||||||
request.Header.Set(header, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
ip := getClientIP(request)
|
|
||||||
if ip != tt.expectedIP {
|
|
||||||
t.Errorf("Expected IP %q, got %q", tt.expectedIP, ip)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
TrustProxyHeaders = originalTrust
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestContainsSQLInjection(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{"' OR '1'='1", true},
|
|
||||||
{"'; DROP TABLE users; --", true},
|
|
||||||
{"UNION SELECT * FROM users", true},
|
|
||||||
{"INSERT INTO users VALUES", true},
|
|
||||||
{"DELETE FROM users", true},
|
|
||||||
{"UPDATE SET", true},
|
|
||||||
{"normal query", false},
|
|
||||||
{"SELECT * FROM posts", false},
|
|
||||||
{"' OR '1'='1'", true},
|
|
||||||
{"union select", true},
|
|
||||||
{"", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.input, func(t *testing.T) {
|
|
||||||
result := containsSQLInjection(tt.input)
|
|
||||||
if result != tt.expected {
|
|
||||||
t.Errorf("containsSQLInjection(%q) = %v, expected %v", tt.input, result, tt.expected)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestContainsXSS(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{"<script>alert('xss')</script>", true},
|
|
||||||
{"javascript:alert('xss')", true},
|
|
||||||
{"onload=alert('xss')", true},
|
|
||||||
{"onerror=alert('xss')", true},
|
|
||||||
{"onclick=alert('xss')", true},
|
|
||||||
{"<iframe>", true},
|
|
||||||
{"<img src='x' onerror='alert(1)'>", true},
|
|
||||||
{"normal content", false},
|
|
||||||
{"<div>safe content</div>", false},
|
|
||||||
{"<SCRIPT>alert('xss')</SCRIPT>", true},
|
|
||||||
{"JAVASCRIPT:alert('xss')", true},
|
|
||||||
{"", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.input, func(t *testing.T) {
|
|
||||||
result := containsXSS(tt.input)
|
|
||||||
if result != tt.expected {
|
|
||||||
t.Errorf("containsXSS(%q) = %v, expected %v", tt.input, result, tt.expected)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsSuspiciousUserAgent(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
userAgent string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{"sqlmap/1.0", true},
|
|
||||||
{"nikto scanner", true},
|
|
||||||
{"nmap 7.0", true},
|
|
||||||
{"masscan tool", true},
|
|
||||||
{"zap proxy", true},
|
|
||||||
{"burp suite", true},
|
|
||||||
{"w3af scanner", true},
|
|
||||||
{"havij tool", true},
|
|
||||||
{"acunetix scanner", true},
|
|
||||||
{"nessus scanner", true},
|
|
||||||
{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", false},
|
|
||||||
{"curl/7.68.0", false},
|
|
||||||
{"wget/1.20.3", false},
|
|
||||||
{"SQLMAP/1.0", true},
|
|
||||||
{"", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.userAgent, func(t *testing.T) {
|
|
||||||
result := isSuspiciousUserAgent(tt.userAgent)
|
|
||||||
if result != tt.expected {
|
|
||||||
t.Errorf("isSuspiciousUserAgent(%q) = %v, expected %v", tt.userAgent, result, tt.expected)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsRapidRequest(t *testing.T) {
|
|
||||||
|
|
||||||
rapidRequests.mu.Lock()
|
|
||||||
rapidRequests.counts = make(map[string]int)
|
|
||||||
rapidRequests.lastReset = time.Now()
|
|
||||||
rapidRequests.mu.Unlock()
|
|
||||||
|
|
||||||
ip := "192.168.1.1"
|
|
||||||
|
|
||||||
for i := range 50 {
|
|
||||||
if isRapidRequest(ip) {
|
|
||||||
t.Errorf("Request %d should not be considered rapid", i+1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range 110 {
|
|
||||||
result := isRapidRequest(ip)
|
|
||||||
if i < 50 {
|
|
||||||
if result {
|
|
||||||
t.Errorf("Request %d should not be considered rapid yet", i+51)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if !result {
|
|
||||||
t.Errorf("Request %d should be considered rapid", i+51)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSuspiciousActivityMiddleware_SQLInjection(t *testing.T) {
|
|
||||||
|
|
||||||
t.Skip("Skipping due to URL encoding complexities - detection logic tested separately")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSuspiciousActivityMiddleware_XSS(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
logger := &SecurityLogger{
|
|
||||||
logger: log.New(&buf, "[SECURITY] ", log.LstdFlags|log.Lshortfile),
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := SuspiciousActivityMiddleware(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
|
|
||||||
request := httptest.NewRequest("GET", "/javascript:", nil)
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler.ServeHTTP(recorder, request)
|
|
||||||
|
|
||||||
logOutput := buf.String()
|
|
||||||
expectedParts := []string{
|
|
||||||
"[WARN]",
|
|
||||||
"Suspicious Activity",
|
|
||||||
"Potential XSS attempt",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, part := range expectedParts {
|
|
||||||
if !strings.Contains(logOutput, part) {
|
|
||||||
t.Errorf("Expected log output to contain %q, got %q", part, logOutput)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSuspiciousActivityMiddleware_SuspiciousUserAgent(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
logger := &SecurityLogger{
|
|
||||||
logger: log.New(&buf, "[SECURITY] ", log.LstdFlags|log.Lshortfile),
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := SuspiciousActivityMiddleware(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
|
|
||||||
request := httptest.NewRequest("GET", "/test", nil)
|
|
||||||
request.Header.Set("User-Agent", "sqlmap/1.0")
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler.ServeHTTP(recorder, request)
|
|
||||||
|
|
||||||
logOutput := buf.String()
|
|
||||||
expectedParts := []string{
|
|
||||||
"[WARN]",
|
|
||||||
"Suspicious Activity",
|
|
||||||
"Suspicious user agent",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, part := range expectedParts {
|
|
||||||
if !strings.Contains(logOutput, part) {
|
|
||||||
t.Errorf("Expected log output to contain %q, got %q", part, logOutput)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSuspiciousActivityMiddleware_NoSuspiciousActivity(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
logger := &SecurityLogger{
|
|
||||||
logger: log.New(&buf, "[SECURITY] ", log.LstdFlags|log.Lshortfile),
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := SuspiciousActivityMiddleware(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
|
|
||||||
request := httptest.NewRequest("GET", "/test", nil)
|
|
||||||
request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler.ServeHTTP(recorder, request)
|
|
||||||
|
|
||||||
logOutput := buf.String()
|
|
||||||
if logOutput != "" {
|
|
||||||
t.Errorf("Expected no log output for normal request, got %q", logOutput)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSuspiciousActivityMiddleware_EncodedSQLInQuery(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
logger := &SecurityLogger{
|
|
||||||
logger: log.New(&buf, "[SECURITY] ", log.LstdFlags|log.Lshortfile),
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := SuspiciousActivityMiddleware(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
|
|
||||||
q := url.Values{}
|
|
||||||
q.Set("s", "' OR '1'='1")
|
|
||||||
req := httptest.NewRequest("GET", "/search?"+q.Encode(), nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
out := buf.String()
|
|
||||||
if !strings.Contains(out, "SQL injection") {
|
|
||||||
t.Fatalf("expected SQL injection log, got %q", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSuspiciousActivityMiddleware_Debug(t *testing.T) {
|
|
||||||
|
|
||||||
t.Run("SQL Detection", func(t *testing.T) {
|
|
||||||
if !containsSQLInjection("INSERT INTO") {
|
|
||||||
t.Error("INSERT INTO should be detected as SQL injection")
|
|
||||||
}
|
|
||||||
if !containsSQLInjection("UNION SELECT") {
|
|
||||||
t.Error("UNION SELECT should be detected as SQL injection")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("XSS Detection", func(t *testing.T) {
|
|
||||||
if !containsXSS("onload=") {
|
|
||||||
t.Error("onload= should be detected as XSS")
|
|
||||||
}
|
|
||||||
if !containsXSS("javascript:") {
|
|
||||||
t.Error("javascript: should be detected as XSS")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSecurityResponseWriter(t *testing.T) {
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
wrapped := &securityResponseWriter{ResponseWriter: recorder, statusCode: http.StatusOK}
|
|
||||||
|
|
||||||
wrapped.WriteHeader(http.StatusCreated)
|
|
||||||
if wrapped.statusCode != http.StatusCreated {
|
|
||||||
t.Errorf("Expected status code %d, got %d", http.StatusCreated, wrapped.statusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
if recorder.Result().StatusCode != http.StatusCreated {
|
|
||||||
t.Errorf("Expected underlying writer status code %d, got %d", http.StatusCreated, recorder.Result().StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -32,6 +33,9 @@ const (
|
|||||||
tlsHandshakeTimeout = 5 * time.Second
|
tlsHandshakeTimeout = 5 * time.Second
|
||||||
responseHeaderTimeout = 5 * time.Second
|
responseHeaderTimeout = 5 * time.Second
|
||||||
maxContentLength = 10 * 1024 * 1024
|
maxContentLength = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
dnsPinTTL = 5 * time.Minute
|
||||||
|
maxDNSPinnedHosts = 1024
|
||||||
)
|
)
|
||||||
|
|
||||||
type TitleFetcher interface {
|
type TitleFetcher interface {
|
||||||
@@ -48,65 +52,66 @@ func (d DefaultDNSResolver) LookupIP(hostname string) ([]net.IP, error) {
|
|||||||
return net.LookupIP(hostname)
|
return net.LookupIP(hostname)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type dnsCacheEntry struct {
|
||||||
|
ips []net.IP
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type DNSCache struct {
|
type DNSCache struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
data map[string][]net.IP
|
data map[string]dnsCacheEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDNSCache() *DNSCache {
|
func NewDNSCache() *DNSCache {
|
||||||
return &DNSCache{
|
return &DNSCache{
|
||||||
data: make(map[string][]net.IP),
|
data: make(map[string]dnsCacheEntry),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DNSCache) Get(hostname string) ([]net.IP, bool) {
|
func (c *DNSCache) Get(hostname string) ([]net.IP, bool) {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
ips, exists := c.data[hostname]
|
entry, exists := c.data[hostname]
|
||||||
return ips, exists
|
if !exists || time.Now().After(entry.expiresAt) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return entry.ips, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *DNSCache) Set(hostname string, ips []net.IP) {
|
func (c *DNSCache) Set(hostname string, ips []net.IP) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.data[hostname] = ips
|
|
||||||
}
|
|
||||||
|
|
||||||
type CachedDNSResolver struct {
|
if len(c.data) >= maxDNSPinnedHosts {
|
||||||
resolver DNSResolver
|
now := time.Now()
|
||||||
cache *DNSCache
|
for host, entry := range c.data {
|
||||||
|
if now.After(entry.expiresAt) {
|
||||||
|
delete(c.data, host)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
func NewCachedDNSResolver(resolver DNSResolver) *CachedDNSResolver {
|
for host := range c.data {
|
||||||
return &CachedDNSResolver{
|
if len(c.data) < maxDNSPinnedHosts {
|
||||||
resolver: resolver,
|
break
|
||||||
cache: NewDNSCache(),
|
}
|
||||||
|
delete(c.data, host)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CachedDNSResolver) LookupIP(hostname string) ([]net.IP, error) {
|
c.data[hostname] = dnsCacheEntry{
|
||||||
if ips, exists := c.cache.Get(hostname); exists {
|
ips: ips,
|
||||||
return ips, nil
|
expiresAt: time.Now().Add(dnsPinTTL),
|
||||||
}
|
}
|
||||||
|
|
||||||
ips, err := c.resolver.LookupIP(hostname)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
c.cache.Set(hostname, ips)
|
|
||||||
return ips, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CustomDialer struct {
|
type CustomDialer struct {
|
||||||
cache *DNSCache
|
cache *DNSCache
|
||||||
fallback *net.Dialer
|
dialer *net.Dialer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCustomDialer(cache *DNSCache) *CustomDialer {
|
func NewCustomDialer(cache *DNSCache) *CustomDialer {
|
||||||
return &CustomDialer{
|
return &CustomDialer{
|
||||||
cache: cache,
|
cache: cache,
|
||||||
fallback: &net.Dialer{
|
dialer: &net.Dialer{
|
||||||
Timeout: dialTimeout,
|
Timeout: dialTimeout,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -115,40 +120,43 @@ func NewCustomDialer(cache *DNSCache) *CustomDialer {
|
|||||||
func (d *CustomDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
func (d *CustomDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
host, port, err := net.SplitHostPort(address)
|
host, port, err := net.SplitHostPort(address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return d.fallback.DialContext(ctx, network, address)
|
return nil, ErrSSRFBlocked
|
||||||
}
|
}
|
||||||
|
|
||||||
if ips, exists := d.cache.Get(host); exists {
|
ips, exists := d.cache.Get(host)
|
||||||
|
if !exists {
|
||||||
|
return nil, ErrSSRFBlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
for _, ip := range ips {
|
for _, ip := range ips {
|
||||||
ipAddr := net.JoinHostPort(ip.String(), port)
|
conn, err := d.dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||||
if conn, err := d.fallback.DialContext(ctx, network, ipAddr); err == nil {
|
if err == nil {
|
||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
}
|
lastErr = err
|
||||||
}
|
}
|
||||||
|
|
||||||
return d.fallback.DialContext(ctx, network, address)
|
if lastErr == nil {
|
||||||
|
lastErr = ErrSSRFBlocked
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
type URLMetadataService struct {
|
type URLMetadataService struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
resolver DNSResolver
|
resolver DNSResolver
|
||||||
dnsCache *DNSCache
|
dnsCache *DNSCache
|
||||||
approvedHosts map[string]bool
|
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewURLMetadataService() *URLMetadataService {
|
func NewURLMetadataService() *URLMetadataService {
|
||||||
dnsCache := NewDNSCache()
|
|
||||||
cachedResolver := NewCachedDNSResolver(DefaultDNSResolver{})
|
|
||||||
customDialer := NewCustomDialer(dnsCache)
|
|
||||||
|
|
||||||
svc := &URLMetadataService{
|
svc := &URLMetadataService{
|
||||||
resolver: cachedResolver,
|
resolver: DefaultDNSResolver{},
|
||||||
dnsCache: dnsCache,
|
dnsCache: NewDNSCache(),
|
||||||
approvedHosts: make(map[string]bool),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
customDialer := NewCustomDialer(svc.dnsCache)
|
||||||
|
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
DialContext: customDialer.DialContext,
|
DialContext: customDialer.DialContext,
|
||||||
MaxIdleConns: 100,
|
MaxIdleConns: 100,
|
||||||
@@ -166,25 +174,7 @@ func NewURLMetadataService() *URLMetadataService {
|
|||||||
if len(via) >= maxRedirects {
|
if len(via) >= maxRedirects {
|
||||||
return ErrTooManyRedirects
|
return ErrTooManyRedirects
|
||||||
}
|
}
|
||||||
|
return svc.validateURLForSSRF(req.URL)
|
||||||
hostname := req.URL.Hostname()
|
|
||||||
svc.mu.RLock()
|
|
||||||
approved := svc.approvedHosts[hostname]
|
|
||||||
svc.mu.RUnlock()
|
|
||||||
|
|
||||||
if approved {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := svc.validateURLForSSRF(req.URL); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
svc.mu.Lock()
|
|
||||||
svc.approvedHosts[hostname] = true
|
|
||||||
svc.mu.Unlock()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return svc
|
return svc
|
||||||
@@ -204,21 +194,10 @@ func (s *URLMetadataService) FetchTitle(ctx context.Context, rawURL string) (str
|
|||||||
return "", ErrUnsupportedScheme
|
return "", ErrUnsupportedScheme
|
||||||
}
|
}
|
||||||
|
|
||||||
hostname := parsed.Hostname()
|
|
||||||
s.mu.RLock()
|
|
||||||
approved := s.approvedHosts[hostname]
|
|
||||||
s.mu.RUnlock()
|
|
||||||
|
|
||||||
if !approved {
|
|
||||||
if err := s.validateURLForSSRF(parsed); err != nil {
|
if err := s.validateURLForSSRF(parsed); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
s.approvedHosts[hostname] = true
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("build request: %w", err)
|
return "", fmt.Errorf("build request: %w", err)
|
||||||
@@ -336,13 +315,20 @@ func (s *URLMetadataService) validateURLForSSRF(u *url.URL) error {
|
|||||||
return ErrSSRFBlocked
|
return ErrSSRFBlocked
|
||||||
}
|
}
|
||||||
|
|
||||||
ips, err := s.resolver.LookupIP(u.Hostname())
|
hostname := u.Hostname()
|
||||||
if err != nil {
|
if _, pinned := s.dnsCache.Get(hostname); pinned {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ips, err := s.resolver.LookupIP(hostname)
|
||||||
|
if err != nil || len(ips) == 0 {
|
||||||
return ErrSSRFBlocked
|
return ErrSSRFBlocked
|
||||||
}
|
}
|
||||||
if slices.ContainsFunc(ips, isPrivateOrReservedIP) {
|
if slices.ContainsFunc(ips, isPrivateOrReservedIP) {
|
||||||
return ErrSSRFBlocked
|
return ErrSSRFBlocked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.dnsCache.Set(hostname, ips)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,93 +347,42 @@ func isLocalhost(hostname string) bool {
|
|||||||
return slices.Contains(localhostNames, hostname)
|
return slices.Contains(localhostNames, hostname)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var reservedPrefixes = []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("0.0.0.0/8"),
|
||||||
|
netip.MustParsePrefix("100.64.0.0/10"),
|
||||||
|
netip.MustParsePrefix("192.0.0.0/24"),
|
||||||
|
netip.MustParsePrefix("192.0.2.0/24"),
|
||||||
|
netip.MustParsePrefix("198.18.0.0/15"),
|
||||||
|
netip.MustParsePrefix("198.51.100.0/24"),
|
||||||
|
netip.MustParsePrefix("203.0.113.0/24"),
|
||||||
|
netip.MustParsePrefix("240.0.0.0/4"),
|
||||||
|
netip.MustParsePrefix("64:ff9b::/96"),
|
||||||
|
netip.MustParsePrefix("100::/64"),
|
||||||
|
netip.MustParsePrefix("2001:db8::/32"),
|
||||||
|
}
|
||||||
|
|
||||||
func isPrivateOrReservedIP(ip net.IP) bool {
|
func isPrivateOrReservedIP(ip net.IP) bool {
|
||||||
if ip == nil {
|
addr, ok := netip.AddrFromSlice(ip)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
addr = addr.Unmap()
|
||||||
|
|
||||||
|
if addr.IsLoopback() ||
|
||||||
|
addr.IsPrivate() ||
|
||||||
|
addr.IsLinkLocalUnicast() ||
|
||||||
|
addr.IsLinkLocalMulticast() ||
|
||||||
|
addr.IsInterfaceLocalMulticast() ||
|
||||||
|
addr.IsMulticast() ||
|
||||||
|
addr.IsUnspecified() {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
ipv4 := ip.To4()
|
for _, prefix := range reservedPrefixes {
|
||||||
if ipv4 == nil {
|
if prefix.Contains(addr) {
|
||||||
return isPrivateIPv6(ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
privateRanges := []struct {
|
|
||||||
start, end net.IP
|
|
||||||
}{
|
|
||||||
{net.IPv4(10, 0, 0, 0), net.IPv4(10, 255, 255, 255)},
|
|
||||||
{net.IPv4(172, 16, 0, 0), net.IPv4(172, 31, 255, 255)},
|
|
||||||
{net.IPv4(192, 168, 0, 0), net.IPv4(192, 168, 255, 255)},
|
|
||||||
{net.IPv4(127, 0, 0, 0), net.IPv4(127, 255, 255, 255)},
|
|
||||||
{net.IPv4(169, 254, 0, 0), net.IPv4(169, 254, 255, 255)},
|
|
||||||
{net.IPv4(224, 0, 0, 0), net.IPv4(239, 255, 255, 255)},
|
|
||||||
{net.IPv4(240, 0, 0, 0), net.IPv4(255, 255, 255, 255)},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, r := range privateRanges {
|
|
||||||
if ipInRange(ipv4, r.start, r.end) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func isPrivateIPv6(ip net.IP) bool {
|
|
||||||
privateRanges := []struct {
|
|
||||||
prefix []byte
|
|
||||||
length int
|
|
||||||
}{
|
|
||||||
{[]byte{0xfc, 0x00}, 7},
|
|
||||||
{[]byte{0xfe, 0x80}, 10},
|
|
||||||
{[]byte{0xff, 0x00}, 8},
|
|
||||||
{[]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}, 128},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, r := range privateRanges {
|
|
||||||
if ipv6InRange(ip, r.prefix, r.length) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func ipInRange(ip, start, end net.IP) bool {
|
|
||||||
ipInt := ipToInt(ip)
|
|
||||||
startInt := ipToInt(start)
|
|
||||||
endInt := ipToInt(end)
|
|
||||||
return ipInt >= startInt && ipInt <= endInt
|
|
||||||
}
|
|
||||||
|
|
||||||
func ipToInt(ip net.IP) uint32 {
|
|
||||||
ipv4 := ip.To4()
|
|
||||||
if ipv4 == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return uint32(ipv4[0])<<24 + uint32(ipv4[1])<<16 + uint32(ipv4[2])<<8 + uint32(ipv4[3])
|
|
||||||
}
|
|
||||||
|
|
||||||
func ipv6InRange(ip net.IP, prefix []byte, length int) bool {
|
|
||||||
ipBytes := ip.To16()
|
|
||||||
if ipBytes == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
bytesToCompare := length / 8
|
|
||||||
bitsToCompare := length % 8
|
|
||||||
|
|
||||||
for i := 0; i < bytesToCompare && i < len(prefix) && i < len(ipBytes); i++ {
|
|
||||||
if ipBytes[i] != prefix[i] {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if bitsToCompare > 0 && bytesToCompare < len(prefix) && bytesToCompare < len(ipBytes) {
|
|
||||||
mask := byte(0xff) << (8 - bitsToCompare)
|
|
||||||
if (ipBytes[bytesToCompare] & mask) != (prefix[bytesToCompare] & mask) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFetchTitleSuccess(t *testing.T) {
|
func TestFetchTitleSuccess(t *testing.T) {
|
||||||
@@ -428,10 +429,38 @@ func TestIsPrivateOrReservedIP(t *testing.T) {
|
|||||||
{"169.254.0.1", "169.254.0.1", true},
|
{"169.254.0.1", "169.254.0.1", true},
|
||||||
{"224.0.0.1", "224.0.0.1", true},
|
{"224.0.0.1", "224.0.0.1", true},
|
||||||
{"240.0.0.1", "240.0.0.1", true},
|
{"240.0.0.1", "240.0.0.1", true},
|
||||||
|
{"0.0.0.0", "0.0.0.0", true},
|
||||||
|
{"0.1.2.3", "0.1.2.3", true},
|
||||||
|
{"CGNAT 100.64.0.1", "100.64.0.1", true},
|
||||||
|
{"CGNAT 100.127.255.255", "100.127.255.255", true},
|
||||||
|
{"IETF 192.0.0.1", "192.0.0.1", true},
|
||||||
|
{"TEST-NET-1 192.0.2.1", "192.0.2.1", true},
|
||||||
|
{"benchmarking 198.18.0.1", "198.18.0.1", true},
|
||||||
|
{"benchmarking 198.19.255.255", "198.19.255.255", true},
|
||||||
|
{"TEST-NET-2 198.51.100.1", "198.51.100.1", true},
|
||||||
|
{"TEST-NET-3 203.0.113.1", "203.0.113.1", true},
|
||||||
|
{"broadcast 255.255.255.255", "255.255.255.255", true},
|
||||||
|
{"IPv4-mapped private ::ffff:10.0.0.1", "::ffff:10.0.0.1", true},
|
||||||
|
{"IPv4-mapped loopback ::ffff:127.0.0.1", "::ffff:127.0.0.1", true},
|
||||||
|
|
||||||
|
{"IPv6 loopback ::1", "::1", true},
|
||||||
|
{"IPv6 unspecified ::", "::", true},
|
||||||
|
{"IPv6 ULA fc00::1", "fc00::1", true},
|
||||||
|
{"IPv6 ULA fd00::1", "fd00::1", true},
|
||||||
|
{"IPv6 link-local fe80::1", "fe80::1", true},
|
||||||
|
{"IPv6 multicast ff00::1", "ff00::1", true},
|
||||||
|
{"IPv6 interface-local multicast ff01::1", "ff01::1", true},
|
||||||
|
{"NAT64 64:ff9b::7f00:1", "64:ff9b::7f00:1", true},
|
||||||
|
{"discard-only 100::1", "100::1", true},
|
||||||
|
{"documentation 2001:db8::1", "2001:db8::1", true},
|
||||||
|
|
||||||
{"8.8.8.8", "8.8.8.8", false},
|
{"8.8.8.8", "8.8.8.8", false},
|
||||||
{"1.1.1.1", "1.1.1.1", false},
|
{"1.1.1.1", "1.1.1.1", false},
|
||||||
{"74.125.224.72", "74.125.224.72", false},
|
{"74.125.224.72", "74.125.224.72", false},
|
||||||
|
{"100.128.0.1 just past CGNAT", "100.128.0.1", false},
|
||||||
|
{"IPv6 public 2001:4860::1", "2001:4860::1", false},
|
||||||
|
{"IPv6 public 2607:f8b0::1", "2607:f8b0::1", false},
|
||||||
|
{"IPv4-mapped public ::ffff:8.8.8.8", "::ffff:8.8.8.8", false},
|
||||||
|
|
||||||
{"nil IP", "", true},
|
{"nil IP", "", true},
|
||||||
}
|
}
|
||||||
@@ -848,93 +877,81 @@ func (c *CountingMockDNSResolver) LookupIP(hostname string) ([]net.IP, error) {
|
|||||||
return c.MockDNSResolver.LookupIP(hostname)
|
return c.MockDNSResolver.LookupIP(hostname)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIPv6PrivateRangeDetection(t *testing.T) {
|
func TestCustomDialerRequiresPinnedIP(t *testing.T) {
|
||||||
tests := []struct {
|
cache := NewDNSCache()
|
||||||
name string
|
dialer := NewCustomDialer(cache)
|
||||||
ip string
|
|
||||||
expected bool
|
_, err := dialer.DialContext(context.Background(), "tcp", "unvalidated.example.com:443")
|
||||||
}{
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
{"fc00::1", "fc00::1", true},
|
t.Fatalf("expected ErrSSRFBlocked for unpinned host, got %v", err)
|
||||||
{"fe80::1", "fe80::1", true},
|
|
||||||
{"ff00::1", "ff00::1", true},
|
|
||||||
{"::1", "::1", true},
|
|
||||||
{"2001:db8::1", "2001:db8::1", false},
|
|
||||||
{"2001:4860::1", "2001:4860::1", false},
|
|
||||||
{"2607:f8b0::1", "2607:f8b0::1", false},
|
|
||||||
{"invalid", "invalid", false},
|
|
||||||
{"", "", false},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
_, err = dialer.DialContext(context.Background(), "tcp", "missing-port.example.com")
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
if !errors.Is(err, ErrSSRFBlocked) {
|
||||||
var ip net.IP
|
t.Fatalf("expected ErrSSRFBlocked for invalid address, got %v", err)
|
||||||
if tt.ip != "" && tt.ip != "invalid" {
|
}
|
||||||
ip = net.ParseIP(tt.ip)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result := isPrivateIPv6(ip)
|
func TestDNSCacheExpiry(t *testing.T) {
|
||||||
if result != tt.expected {
|
cache := NewDNSCache()
|
||||||
t.Fatalf("expected %v for IPv6 %q, got %v", tt.expected, tt.ip, result)
|
cache.Set("example.com", []net.IP{net.ParseIP("8.8.8.8")})
|
||||||
|
|
||||||
|
if _, exists := cache.Get("example.com"); !exists {
|
||||||
|
t.Fatal("expected fresh entry to be returned")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cache.mu.Lock()
|
||||||
|
entry := cache.data["example.com"]
|
||||||
|
entry.expiresAt = time.Now().Add(-time.Second)
|
||||||
|
cache.data["example.com"] = entry
|
||||||
|
cache.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := cache.Get("example.com"); exists {
|
||||||
|
t.Fatal("expected expired entry to be treated as a miss")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiredPinTriggersRevalidation(t *testing.T) {
|
||||||
|
svc := NewURLMetadataService()
|
||||||
|
|
||||||
|
lookupCount := 0
|
||||||
|
mockResolver := &CountingMockDNSResolver{
|
||||||
|
MockDNSResolver: MockDNSResolver{
|
||||||
|
lookupResults: make(map[string][]net.IP),
|
||||||
|
lookupErrors: make(map[string]error),
|
||||||
|
},
|
||||||
|
lookupCount: &lookupCount,
|
||||||
|
}
|
||||||
|
mockResolver.SetLookupResult("example.com", []net.IP{net.ParseIP("8.8.8.8")})
|
||||||
|
svc.resolver = mockResolver
|
||||||
|
|
||||||
|
svc.client = newTestClient(t, func(r *http.Request) (*http.Response, error) {
|
||||||
|
body := io.NopCloser(strings.NewReader("<html><head><title>Test Title</title></head></html>"))
|
||||||
|
header := make(http.Header)
|
||||||
|
header.Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: body, Header: header}, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if _, err := svc.FetchTitle(context.Background(), "https://example.com"); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
if lookupCount != 1 {
|
||||||
|
t.Fatalf("expected 1 DNS lookup, got %d", lookupCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIPRangeDetection(t *testing.T) {
|
svc.dnsCache.mu.Lock()
|
||||||
tests := []struct {
|
entry := svc.dnsCache.data["example.com"]
|
||||||
name string
|
entry.expiresAt = time.Now().Add(-time.Second)
|
||||||
ip string
|
svc.dnsCache.data["example.com"] = entry
|
||||||
start string
|
svc.dnsCache.mu.Unlock()
|
||||||
end string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{"IP in range", "192.168.1.100", "192.168.1.1", "192.168.1.255", true},
|
|
||||||
{"IP at start of range", "192.168.1.1", "192.168.1.1", "192.168.1.255", true},
|
|
||||||
{"IP at end of range", "192.168.1.255", "192.168.1.1", "192.168.1.255", true},
|
|
||||||
{"IP below range", "192.168.0.255", "192.168.1.1", "192.168.1.255", false},
|
|
||||||
{"IP above range", "192.168.2.1", "192.168.1.1", "192.168.1.255", false},
|
|
||||||
{"Same IP", "192.168.1.100", "192.168.1.100", "192.168.1.100", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
mockResolver.SetLookupResult("example.com", []net.IP{net.ParseIP("127.0.0.1")})
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
ip := net.ParseIP(tt.ip)
|
|
||||||
start := net.ParseIP(tt.start)
|
|
||||||
end := net.ParseIP(tt.end)
|
|
||||||
|
|
||||||
result := ipInRange(ip, start, end)
|
if _, err := svc.FetchTitle(context.Background(), "https://example.com"); !errors.Is(err, ErrSSRFBlocked) {
|
||||||
if result != tt.expected {
|
t.Fatalf("expected ErrSSRFBlocked after DNS rebind to private IP, got %v", err)
|
||||||
t.Fatalf("expected %v for IP %q in range %q-%q, got %v", tt.expected, tt.ip, tt.start, tt.end, result)
|
|
||||||
}
|
}
|
||||||
})
|
if lookupCount != 2 {
|
||||||
}
|
t.Fatalf("expected expired pin to trigger a second DNS lookup, got %d", lookupCount)
|
||||||
}
|
|
||||||
|
|
||||||
func TestIPv6RangeDetection(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
ip string
|
|
||||||
prefix []byte
|
|
||||||
length int
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{"fc00 prefix match", "fc00::1", []byte{0xfc, 0x00}, 7, true},
|
|
||||||
{"fc00 prefix no match", "fd00::1", []byte{0xfc, 0x00}, 7, true},
|
|
||||||
{"fe80 prefix match", "fe80::1", []byte{0xfe, 0x80}, 10, true},
|
|
||||||
{"fe80 prefix no match", "fe90::1", []byte{0xfe, 0x80}, 10, true},
|
|
||||||
{"ff00 prefix match", "ff00::1", []byte{0xff, 0x00}, 8, true},
|
|
||||||
{"ff00 prefix no match", "fe00::1", []byte{0xff, 0x00}, 8, false},
|
|
||||||
{"exact match", "::1", []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}, 128, true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
ip := net.ParseIP(tt.ip)
|
|
||||||
result := ipv6InRange(ip, tt.prefix, tt.length)
|
|
||||||
if result != tt.expected {
|
|
||||||
t.Fatalf("expected %v for IPv6 %q with prefix %v/%d, got %v", tt.expected, tt.ip, tt.prefix, tt.length, result)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user