feat: enhance security with IP banning, ownership checks, and SSRF protection
Add comprehensive security improvements across the application: - **IP-based login protection**: Implement IP ban system tracking login failures, auto-banning after threshold exceeded - **Ownership verification**: Add userID parameter to Delete/Update operations for posts and comments to prevent unauthorized modifications - **SSRF protection**: Add URL and resolved host validation for image URLs in chat and OpenAI client - **SQL injection prevention**: Add EscapeLikeWildcard utility to escape special characters in LIKE queries - **HTTP security**: Configure server timeouts and add security headers middleware - **Rate limiting**: Refactor to support configurable duration and per-endpoint rate limits for auth routes - **Error handling**: Standardize error responses using HandleError and proper error types
This commit is contained in:
29
internal/pkg/netutil/safeclient.go
Normal file
29
internal/pkg/netutil/safeclient.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package netutil
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func NewSafeHTTPClient(timeout time.Duration) *http.Client {
|
||||
safeDialer := &SafeDialer{
|
||||
Dialer: net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
DialContext: safeDialer.DialContext,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
134
internal/pkg/netutil/ssrf.go
Normal file
134
internal/pkg/netutil/ssrf.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package netutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func isPrivateIP(ip net.IP) bool {
|
||||
privateRanges := []struct {
|
||||
network *net.IPNet
|
||||
}{
|
||||
{mustParseCIDR("10.0.0.0/8")},
|
||||
{mustParseCIDR("172.16.0.0/12")},
|
||||
{mustParseCIDR("192.168.0.0/16")},
|
||||
{mustParseCIDR("127.0.0.0/8")},
|
||||
{mustParseCIDR("169.254.0.0/16")},
|
||||
{mustParseCIDR("0.0.0.0/8")},
|
||||
{mustParseCIDR("100.64.0.0/10")},
|
||||
{mustParseCIDR("198.18.0.0/15")},
|
||||
{mustParseCIDR("::1/128")},
|
||||
{mustParseCIDR("fc00::/7")},
|
||||
{mustParseCIDR("fe80::/10")},
|
||||
{mustParseCIDR("::ffff:127.0.0.0/104")},
|
||||
}
|
||||
|
||||
for _, r := range privateRanges {
|
||||
if r.network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mustParseCIDR(s string) *net.IPNet {
|
||||
_, n, err := net.ParseCIDR(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
type SafeDialer struct {
|
||||
Dialer net.Dialer
|
||||
}
|
||||
|
||||
func (d *SafeDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("netutil: invalid address %q: %w", addr, err)
|
||||
}
|
||||
|
||||
resolver := net.Resolver{}
|
||||
ips, err := resolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("netutil: DNS lookup failed for %q: %w", host, err)
|
||||
}
|
||||
|
||||
for _, ipAddr := range ips {
|
||||
if isPrivateIP(ipAddr.IP) {
|
||||
return nil, fmt.Errorf("netutil: resolved IP %s of %q is a private/reserved address", ipAddr.IP, host)
|
||||
}
|
||||
}
|
||||
|
||||
var targetIPs []string
|
||||
for _, ipAddr := range ips {
|
||||
targetIPs = append(targetIPs, ipAddr.IP.String())
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, ipStr := range targetIPs {
|
||||
conn, err := d.Dialer.DialContext(ctx, network, net.JoinHostPort(ipStr, port))
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
remoteAddr, ok := conn.RemoteAddr().(*net.TCPAddr)
|
||||
if ok && isPrivateIP(remoteAddr.IP) {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("netutil: connected IP %s is a private/reserved address (DNS rebinding detected)", remoteAddr.IP)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, fmt.Errorf("netutil: no reachable IP for %q", host)
|
||||
}
|
||||
|
||||
func ValidateURL(rawURL string) error {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("netutil: invalid URL: %w", err)
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return fmt.Errorf("netutil: scheme %q is not allowed, only http and https are permitted", u.Scheme)
|
||||
}
|
||||
|
||||
if u.Host == "" {
|
||||
return fmt.Errorf("netutil: URL missing host")
|
||||
}
|
||||
|
||||
hostname := u.Hostname()
|
||||
if hostname == "" {
|
||||
return fmt.Errorf("netutil: URL missing hostname")
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(hostname); ip != nil {
|
||||
if isPrivateIP(ip) {
|
||||
return fmt.Errorf("netutil: host IP %s is a private/reserved address", ip)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CheckResolvedHost(hostname string) error {
|
||||
ips, err := net.LookupIP(hostname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("netutil: DNS lookup failed for %q: %w", hostname, err)
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if isPrivateIP(ip) {
|
||||
return fmt.Errorf("netutil: resolved IP %s of %q is a private/reserved address", ip, hostname)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user