410a131cec
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.
143 lines
5.0 KiB
Go
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,
|
|
}
|