Files
tiny-forge/internal/staticsite/safehttp.go
T
alexei.dolgolyov 410a131cec feat(apps): stepped creation wizard, branch previews, and app-creation fixes
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
  WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
  ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
  + {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
  /apps/[id] edit form onto the same components (removes the duplication). Add
  vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
  environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
  state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
  conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
  label hints; dashboard + /apps "Total workloads" count only source_kind workloads
  (drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
  empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.

Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
2026-05-29 02:09:54 +03:00

143 lines
5.0 KiB
Go

package staticsite
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// ErrBlockedAddress is returned when the dialer refuses to connect
// to a reserved IP (loopback / link-local / unspecified / multicast).
// RFC1918 private ranges are intentionally allowed — self-hosted Gitea
// on a LAN is the dominant deployment pattern.
var ErrBlockedAddress = errors.New("connection to reserved address blocked")
// ValidateBaseURL enforces scheme + host shape on a user-supplied
// provider base URL. Connect-time IP filtering happens later in the
// safe-HTTP transport so DNS rebinding cannot bypass this check.
func ValidateBaseURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
return errors.New("base_url is required")
}
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("invalid base_url: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("unsupported scheme %q (must be http or https)", u.Scheme)
}
if u.Host == "" {
return errors.New("base_url is missing host")
}
return nil
}
// NewSafeHTTPClient returns an http.Client whose DialContext rejects
// loopback, link-local, multicast, and unspecified addresses at connect
// time. The dialer re-resolves and connects to the resolved IP so a
// rebind between resolution and connect cannot slip through.
//
// RFC1918 / ULA private ranges are NOT blocked — operators routinely
// point Tinyforge at self-hosted Gitea instances on private networks.
// The threat model here is cloud-metadata exfiltration and loopback
// service probing, not "any private IP is suspect".
func NewSafeHTTPClient(timeout time.Duration) *http.Client {
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
transport := &http.Transport{
DialContext: SafeDialContext(dialer),
MaxIdleConns: 16,
IdleConnTimeout: 30 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
}
return &http.Client{Timeout: timeout, Transport: transport}
}
// SafeDialContext returns a DialContext that rejects loopback, link-local,
// multicast, unspecified, and cloud-metadata addresses at connect time,
// re-resolving and binding to the resolved IP so a DNS rebind between
// resolution and connect cannot slip through. Exposed so other transports
// (e.g. the outbound notification client) can apply the same SSRF policy
// without duplicating it or losing their own connection-pool tuning.
func SafeDialContext(dialer *net.Dialer) func(ctx context.Context, network, addr string) (net.Conn, error) {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
// If the caller passed a literal IP, skip the DNS round-trip.
if literal := net.ParseIP(host); literal != nil {
if reason := blockReason(literal); reason != "" {
return nil, fmt.Errorf("%w: %s (%s)", ErrBlockedAddress, literal, reason)
}
return dialer.DialContext(ctx, network, addr)
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
if len(ips) == 0 {
return nil, fmt.Errorf("no addresses for %s", host)
}
for _, ip := range ips {
if reason := blockReason(ip.IP); reason != "" {
return nil, fmt.Errorf("%w: %s (%s)", ErrBlockedAddress, ip.IP, reason)
}
}
// Bind to the first resolved IP so a rebind between resolution
// and connect cannot redirect the request to a blocked address.
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
}
}
// blockReason returns a human label for why an IP is rejected, or ""
// if the IP is allowed. Centralized so all callers share the same
// policy.
func blockReason(ip net.IP) string {
if ip == nil {
return "nil address"
}
// Normalize IPv4-mapped IPv6 (::ffff:x.x.x.x) so the loopback / link-local
// classifiers below catch them. net.IP.To4() returns the 4-byte form for
// IPv4-mapped addresses; net's IsLoopback already handles this, but pin
// the conversion to avoid future surprises if the std-lib semantics drift.
if v4 := ip.To4(); v4 != nil {
ip = v4
}
switch {
case ip.IsLoopback():
return "loopback"
case ip.IsUnspecified():
return "unspecified"
case ip.IsLinkLocalUnicast():
return "link-local"
case ip.IsLinkLocalMulticast():
return "link-local multicast"
case ip.IsMulticast():
return "multicast"
}
// Cloud metadata endpoints — AWS / GCP / Azure are covered by the
// link-local block (169.254.169.254). The rest must be enumerated.
if metadataIPSet[ip.String()] {
return "cloud metadata endpoint"
}
return ""
}
// metadataIPSet enumerates well-known cloud metadata IPs that are NOT
// covered by net.IP.IsLinkLocalUnicast. Updating this set is the lightest
// way to keep up with new providers without changing the policy shape.
var metadataIPSet = map[string]bool{
// Alibaba Cloud ECS metadata.
"100.100.100.200": true,
// Oracle Cloud Infrastructure metadata.
"192.0.0.192": true,
// AWS IMDS over IPv6 (ULA — not link-local, must be listed).
"fd00:ec2::254": true,
}