feat: enhance security with IP banning, ownership checks, and SSRF protection
All checks were successful
Build Backend / build (push) Successful in 1m56s
Build Backend / build-docker (push) Successful in 1m15s

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:
lafay
2026-04-30 12:26:25 +08:00
parent 67a5660952
commit b2b55ea52d
24 changed files with 517 additions and 80 deletions

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

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