Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/auto-changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ jobs:
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Draft Release
uses: release-drafter/release-drafter@3a7fb5c85b80b1dda66e1ccb94009adbbd32fce3 # v7
uses: release-drafter/release-drafter@44a942e465867c7465b76aa808ddca6e0acae5da # v7
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ ARG CADDY_CANDIDATE_VERSION=2.11.2
ARG CADDY_USE_CANDIDATE=0
ARG CADDY_PATCH_SCENARIO=B
# renovate: datasource=go depName=github.com/greenpau/caddy-security
ARG CADDY_SECURITY_VERSION=1.1.48
ARG CADDY_SECURITY_VERSION=1.1.49
# renovate: datasource=go depName=github.com/corazawaf/coraza-caddy
ARG CORAZA_CADDY_VERSION=2.2.0
## When an official caddy image tag isn't available on the host, use a
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/api/handlers/settings_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1571,7 +1571,7 @@ func TestSettingsHandler_TestPublicURL_SSRFProtection(t *testing.T) {
url: "http://169.254.169.254",
expectedStatus: http.StatusOK,
expectedReachable: false,
errorContains: "private",
errorContains: "cloud metadata",
},
{
name: "blocks link-local",
Expand Down
18 changes: 9 additions & 9 deletions backend/internal/models/seed.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ import (
// overwritten. Returns the upserted record and any error encountered.
func SeedDefaultSecurityConfig(db *gorm.DB) (*SecurityConfig, error) {
record := SecurityConfig{
UUID: uuid.NewString(),
Name: "default",
Enabled: false,
CrowdSecMode: "disabled",
CrowdSecAPIURL: "http://127.0.0.1:8085",
WAFMode: "disabled",
WAFParanoiaLevel: 1,
RateLimitMode: "disabled",
RateLimitEnable: false,
UUID: uuid.NewString(),
Name: "default",
Enabled: false,
CrowdSecMode: "disabled",
CrowdSecAPIURL: "http://127.0.0.1:8085",
WAFMode: "disabled",
WAFParanoiaLevel: 1,
RateLimitMode: "disabled",
RateLimitEnable: false,
// Zero values are intentional for the disabled default state.
// cerberus.RateLimitMiddleware guards against zero/negative values by falling
// back to safe operational defaults (requests=100, window=60s, burst=20) before
Expand Down
94 changes: 94 additions & 0 deletions backend/internal/network/safeclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,22 @@ var (
initOnce sync.Once
)

// rfc1918Blocks holds pre-parsed CIDR blocks for RFC 1918 private address ranges only.
// Initialized once and used by IsRFC1918 to support the AllowRFC1918 bypass path.
var (
rfc1918Blocks []*net.IPNet
rfc1918Once sync.Once
)

// rfc1918CIDRs enumerates exactly the three RFC 1918 private address ranges.
// Intentionally excludes loopback, link-local, cloud metadata (169.254.x.x),
// and all other reserved ranges — those remain blocked regardless of AllowRFC1918.
var rfc1918CIDRs = []string{
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
}

// privateCIDRs defines all private and reserved IP ranges to block for SSRF protection.
// This list covers:
// - RFC 1918 private networks (10.x, 172.16-31.x, 192.168.x)
Expand Down Expand Up @@ -68,6 +84,21 @@ func initPrivateBlocks() {
})
}

// initRFC1918Blocks parses the three RFC 1918 CIDR blocks once at startup.
func initRFC1918Blocks() {
rfc1918Once.Do(func() {
rfc1918Blocks = make([]*net.IPNet, 0, len(rfc1918CIDRs))
for _, cidr := range rfc1918CIDRs {
_, block, err := net.ParseCIDR(cidr)
if err != nil {
// This should never happen with valid CIDR strings
continue
}
rfc1918Blocks = append(rfc1918Blocks, block)
}
})
}

// IsPrivateIP checks if an IP address is private, loopback, link-local, or otherwise restricted.
// This function implements comprehensive SSRF protection by blocking:
// - Private IPv4 ranges (RFC 1918): 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
Expand Down Expand Up @@ -110,6 +141,35 @@ func IsPrivateIP(ip net.IP) bool {
return false
}

// IsRFC1918 reports whether an IP address belongs to one of the three RFC 1918
// private address ranges: 10.0.0.0/8, 172.16.0.0/12, or 192.168.0.0/16.
//
// Unlike IsPrivateIP, this function only covers RFC 1918 ranges. It does NOT
// return true for loopback, link-local (169.254.x.x), cloud metadata endpoints,
// or any other reserved ranges. Use this to implement the AllowRFC1918 bypass
// while keeping all other SSRF protections in place.
//
// Exported so url_validator.go (package security) can call it without duplicating logic.
func IsRFC1918(ip net.IP) bool {
if ip == nil {
return false
}

initRFC1918Blocks()

// Normalise IPv4-mapped IPv6 addresses (::ffff:192.168.x.x → 192.168.x.x)
if ip4 := ip.To4(); ip4 != nil {
ip = ip4
}

for _, block := range rfc1918Blocks {
if block.Contains(ip) {
return true
}
}
return false
}

// ClientOptions configures the behavior of the safe HTTP client.
type ClientOptions struct {
// Timeout is the total request timeout (default: 10s)
Expand All @@ -129,6 +189,14 @@ type ClientOptions struct {

// DialTimeout is the connection timeout for individual dial attempts (default: 5s)
DialTimeout time.Duration

// AllowRFC1918 permits connections to RFC 1918 private address ranges:
// 10.0.0.0/8, 172.16.0.0/12, and 192.168.0.0/16.
//
// SECURITY NOTE: Enable only for admin-configured features (e.g., uptime monitors
// targeting internal hosts). All other restricted ranges — loopback, link-local,
// cloud metadata (169.254.x.x), and reserved — remain blocked regardless.
AllowRFC1918 bool
}

// Option is a functional option for configuring ClientOptions.
Expand Down Expand Up @@ -183,6 +251,17 @@ func WithDialTimeout(timeout time.Duration) Option {
}
}

// WithAllowRFC1918 permits connections to RFC 1918 private address ranges
// (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16).
//
// Use only for admin-configured features such as uptime monitors that need to
// reach internal hosts. All other SSRF protections remain active.
func WithAllowRFC1918() Option {
return func(opts *ClientOptions) {
opts.AllowRFC1918 = true
}
}

// safeDialer creates a custom dial function that validates IP addresses at connection time.
// This prevents DNS rebinding attacks by:
// 1. Resolving the hostname to IP addresses
Expand Down Expand Up @@ -225,6 +304,13 @@ func safeDialer(opts *ClientOptions) func(ctx context.Context, network, addr str
continue
}

// Allow RFC 1918 addresses only when explicitly permitted (e.g., admin-configured
// uptime monitors targeting internal hosts). Link-local (169.254.x.x), loopback,
// cloud metadata, and all other restricted ranges remain blocked.
if opts.AllowRFC1918 && IsRFC1918(ip.IP) {
continue
}

if IsPrivateIP(ip.IP) {
return nil, fmt.Errorf("connection to private IP blocked: %s resolved to %s", host, ip.IP)
}
Expand All @@ -237,6 +323,11 @@ func safeDialer(opts *ClientOptions) func(ctx context.Context, network, addr str
selectedIP = ip.IP
break
}
// Select RFC 1918 IPs when the caller has opted in.
if opts.AllowRFC1918 && IsRFC1918(ip.IP) {
selectedIP = ip.IP
break
}
if !IsPrivateIP(ip.IP) {
selectedIP = ip.IP
break
Expand All @@ -255,6 +346,9 @@ func safeDialer(opts *ClientOptions) func(ctx context.Context, network, addr str

// validateRedirectTarget checks if a redirect URL is safe to follow.
// Returns an error if the redirect target resolves to private IPs.
//
// TODO: If MaxRedirects is ever re-enabled for uptime monitors, thread AllowRFC1918
// through this function to permit RFC 1918 redirect targets.
func validateRedirectTarget(req *http.Request, opts *ClientOptions) error {
host := req.URL.Hostname()
if host == "" {
Expand Down
Loading
Loading