mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
0aa3988319
* feat(urlimport): URL fetcher with SSRF guard + content-type detection (TASK-1469) First slice of PLAN-1467's "Insert from URL" feature. Adds the internal/urlimport package with: - fetch.go: SSRF-guarded HTTP GET (10s timeout, 5 MB body cap, redirect re-validation, redacted-error formatting). Blocks loopback, RFC1918, CGNAT, IPv4/IPv6 link-local (incl. 169.254.169.254 cloud-metadata), IPv6 unique-local, and the unspecified address. Hostnames are resolved and every returned IP is checked. - detect.go: Content-type + body-prefix sniff returning "openapi" (JSON or YAML, OpenAPI 3.x or Swagger 2.0) or "generic". Inspects at most 64 KiB. - fetch_test.go: Table-driven SSRF tests covering 24 cases plus happy-path, size-cap, timeout, non-2xx, context-cancel, and a stubbed-transport redirect re-validation. - detect_test.go: 20 detection cases including OpenAPI JSON, Swagger YAML, vendor media types, leading comments, indented-key negatives, and charset-parameter normalization. Package name is urlimport (not "import" — reserved word). No callers yet; the endpoint that consumes Fetcher + Detect lands in TASK-1472. Parent: PLAN-1467. * fix(urlimport): close DNS-rebinding gap + handle >64 KiB OpenAPI JSON per Codex review (round 1) - HIGH: Add safe dialer transport (newSafeTransport). ValidateURL no longer does DNS — the dialer resolves once and validates the resolved IP at dial time, then dials that exact IP. DNS rebinding can no longer slip a public-IP validation past a loopback fetch. ValidateURL becomes a pre-flight (scheme/credentials/IP-literal only) with the canonical guarantee now at the transport layer. - MEDIUM: For JSON bodies over the 64 KiB sniff cap, switch from full Unmarshal (which fails on a truncated tail) to a streaming json.Decoder scan that walks top-level keys and short-circuits as soon as `openapi` or `swagger` is seen. Real-world specs over 64 KiB are now classified correctly, including the case where the `openapi` key is not the first top-level entry. Tests added: - TestFetch_DialerBlocksLoopbackHostname (dial-time rebinding guard) - TestDetect_LargeOpenAPIJSON (>200 KiB OpenAPI body, key first) - TestDetect_LargeOpenAPIJSON_KeyNotFirst (openapi key after huge info) - TestDetect_LargeJSONNotOpenAPI (huge non-OpenAPI stays generic) Removed the DNS-resolution case from TestValidateURL's notes and added a positive case proving hostnames pass the pre-flight (the dial-time check is now the canonical guard). * fix(urlimport): disable env proxy and reuse safe transport per Codex review (round 2) - HIGH: Set Proxy=nil on the safe transport. ProxyFromEnvironment would route via HTTP_PROXY/HTTPS_PROXY, where the dialer connects to the proxy host instead of the target — silently bypassing the hostname-resolution SSRF check inside DialContext. Operators who need an outbound proxy can wire their own trusted transport into Fetcher.Transport. - MEDIUM: Memoize the default safe transport per Fetcher via sync.Once. Previously each Fetch built a fresh *http.Transport whose keep-alive idle-pool stayed in scope until GC, leaking FDs under repeated imports. Now one transport is shared by all Fetch calls on a Fetcher; AllowLocal is captured at first use.
369 lines
12 KiB
Go
369 lines
12 KiB
Go
// Package urlimport fetches remote URLs and converts the response into
|
|
// markdown that can be inserted into a Pad item. The package is the
|
|
// server-side primitive behind the editor's "Insert from URL" toolbar.
|
|
//
|
|
// Fetcher applies a strict SSRF guard, a per-request timeout, and a
|
|
// response-size cap so a malicious or large upstream cannot abuse the
|
|
// in-process HTTP client. The default policy blocks loopback, RFC1918,
|
|
// CGNAT, IPv4 link-local (including 169.254.169.254 cloud-metadata),
|
|
// IPv6 unique local, IPv6 link-local, and the unspecified address.
|
|
//
|
|
// See PLAN-1467 ("Insert from URL — HTML→Markdown utility in the item
|
|
// editor") for the design discussion.
|
|
package urlimport
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// DefaultTimeout caps how long Fetch waits for a single upstream request.
|
|
const DefaultTimeout = 10 * time.Second
|
|
|
|
// DefaultMaxBytes caps how many response bytes Fetch reads before
|
|
// aborting. 5 MB is a generous ceiling for HTML docs and OpenAPI specs
|
|
// without letting a 200 MB PDF or zip-bomb through.
|
|
const DefaultMaxBytes int64 = 5 * 1024 * 1024
|
|
|
|
// DefaultMaxRedirects is the redirect-chain limit applied by Fetch. Every
|
|
// hop in the chain is re-validated against the SSRF guard so the initial
|
|
// check cannot be bypassed via a 302 to an internal host.
|
|
const DefaultMaxRedirects = 5
|
|
|
|
// DefaultUserAgent is the User-Agent header sent with each fetch. Some
|
|
// hosts (Cloudflare, GitHub) serve different content to "default" Go
|
|
// clients vs. branded agents, so we identify as Pad.
|
|
const DefaultUserAgent = "Pad-URLImport/1.0 (+https://getpad.dev)"
|
|
|
|
// FetchResult is the outcome of a successful Fetch.
|
|
type FetchResult struct {
|
|
// URL is the final resolved URL after any redirects.
|
|
URL string
|
|
// StatusCode is the HTTP status from the upstream.
|
|
StatusCode int
|
|
// ContentType is the raw Content-Type header value (with parameters).
|
|
ContentType string
|
|
// Body is the response body, capped to MaxBytes.
|
|
Body []byte
|
|
}
|
|
|
|
// Fetcher performs SSRF-guarded HTTP GETs.
|
|
//
|
|
// The zero value is not usable — call NewFetcher. Fields are exported
|
|
// only to allow tests to flip AllowLocal and inject a Transport;
|
|
// production code should treat the struct as opaque after creation.
|
|
//
|
|
// Fetcher is safe for concurrent use. The default safe-transport is
|
|
// lazily built on first Fetch and reused for subsequent calls so
|
|
// keep-alive connections aren't leaked.
|
|
type Fetcher struct {
|
|
// Transport is the underlying RoundTripper used by the HTTP client.
|
|
// Tests inject a custom transport to point at httptest servers.
|
|
// When nil, Fetcher builds and caches a safe default.
|
|
Transport http.RoundTripper
|
|
// Timeout overrides DefaultTimeout when non-zero.
|
|
Timeout time.Duration
|
|
// MaxBytes overrides DefaultMaxBytes when positive.
|
|
MaxBytes int64
|
|
// MaxRedirects overrides DefaultMaxRedirects when positive.
|
|
MaxRedirects int
|
|
// UserAgent overrides DefaultUserAgent when non-empty.
|
|
UserAgent string
|
|
// AllowLocal disables the SSRF guard. INTENDED FOR TESTS ONLY —
|
|
// production handlers must leave this false.
|
|
AllowLocal bool
|
|
|
|
// safeOnce + safeTransport memoize the default transport so we
|
|
// don't leak per-call *http.Transport instances with their own
|
|
// idle-connection pools.
|
|
safeOnce sync.Once
|
|
safeTransport *http.Transport
|
|
}
|
|
|
|
// NewFetcher returns a Fetcher with production defaults applied.
|
|
func NewFetcher() *Fetcher {
|
|
return &Fetcher{
|
|
Timeout: DefaultTimeout,
|
|
MaxBytes: DefaultMaxBytes,
|
|
MaxRedirects: DefaultMaxRedirects,
|
|
UserAgent: DefaultUserAgent,
|
|
}
|
|
}
|
|
|
|
// Fetch performs a GET against rawURL, re-validating every redirect hop
|
|
// and aborting if the response exceeds the size cap. Returns a wrapped
|
|
// error suitable for surfacing to API callers.
|
|
//
|
|
// The SSRF guard runs in two layers:
|
|
//
|
|
// 1. ValidateURL — fast-fail pre-flight on scheme, credentials,
|
|
// hostname presence, and IP-literal targets. Does NOT do DNS
|
|
// resolution; see (2).
|
|
// 2. A custom dialer that resolves the hostname once and checks every
|
|
// returned IP at dial-time. This is the canonical guard against
|
|
// DNS rebinding: any hostname-lookup happens inside the dialer and
|
|
// the result is reused as the dial target, so the IP we vetted is
|
|
// the IP we connect to.
|
|
func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (*FetchResult, error) {
|
|
if !f.AllowLocal {
|
|
if err := ValidateURL(rawURL); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
maxRedirects := f.MaxRedirects
|
|
if maxRedirects <= 0 {
|
|
maxRedirects = DefaultMaxRedirects
|
|
}
|
|
timeout := f.Timeout
|
|
if timeout <= 0 {
|
|
timeout = DefaultTimeout
|
|
}
|
|
maxBytes := f.MaxBytes
|
|
if maxBytes <= 0 {
|
|
maxBytes = DefaultMaxBytes
|
|
}
|
|
ua := f.UserAgent
|
|
if ua == "" {
|
|
ua = DefaultUserAgent
|
|
}
|
|
|
|
transport := f.Transport
|
|
if transport == nil {
|
|
transport = f.defaultTransport(timeout)
|
|
}
|
|
|
|
client := &http.Client{
|
|
Timeout: timeout,
|
|
Transport: transport,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
if len(via) >= maxRedirects {
|
|
return fmt.Errorf("too many redirects (> %d)", maxRedirects)
|
|
}
|
|
if !f.AllowLocal {
|
|
if err := ValidateURL(req.URL.String()); err != nil {
|
|
return fmt.Errorf("redirect to %s blocked: %w", req.URL.Redacted(), err)
|
|
}
|
|
}
|
|
// Strip Authorization on cross-origin redirects — defense in
|
|
// depth even though we never set credentials ourselves.
|
|
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
|
|
req.Header.Del("Authorization")
|
|
req.Header.Del("Cookie")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build request: %w", err)
|
|
}
|
|
req.Header.Set("User-Agent", ua)
|
|
req.Header.Set("Accept", "text/html, application/xhtml+xml, application/xml;q=0.9, application/json;q=0.9, text/plain;q=0.8, */*;q=0.5")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch %s: %w", redactURL(rawURL), err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return nil, fmt.Errorf("upstream returned status %d for %s", resp.StatusCode, redactURL(rawURL))
|
|
}
|
|
|
|
// Read up to maxBytes+1; one byte over the cap is enough to know
|
|
// the upstream exceeded the limit.
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read body: %w", err)
|
|
}
|
|
if int64(len(body)) > maxBytes {
|
|
return nil, fmt.Errorf("response exceeds maximum size of %d bytes", maxBytes)
|
|
}
|
|
|
|
finalURL := rawURL
|
|
if resp.Request != nil && resp.Request.URL != nil {
|
|
finalURL = resp.Request.URL.String()
|
|
}
|
|
|
|
return &FetchResult{
|
|
URL: finalURL,
|
|
StatusCode: resp.StatusCode,
|
|
ContentType: resp.Header.Get("Content-Type"),
|
|
Body: body,
|
|
}, nil
|
|
}
|
|
|
|
// ValidateURL is the pre-flight SSRF guard: it rejects non-http(s)
|
|
// schemes, URLs with embedded credentials, missing hostnames, and
|
|
// IP-literal hosts in private/reserved ranges. It deliberately does
|
|
// NOT do DNS resolution — that happens once at dial-time inside the
|
|
// fetcher's transport (see newSafeTransport), where the resolved IP
|
|
// is both checked and reused as the dial target. Doing DNS here would
|
|
// open a TOCTOU window allowing DNS rebinding: a malicious server
|
|
// returns a public IP for the validation lookup and a private IP for
|
|
// the actual fetch.
|
|
//
|
|
// Callers that just want a quick "is this URL syntactically safe?"
|
|
// check (e.g. UI input validation) can call ValidateURL standalone.
|
|
// Callers that fetch must use Fetcher, which adds the dial-time check.
|
|
func ValidateURL(rawURL string) error {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid URL: %w", err)
|
|
}
|
|
switch strings.ToLower(u.Scheme) {
|
|
case "http", "https":
|
|
// ok
|
|
default:
|
|
return fmt.Errorf("unsupported scheme %q: only http and https are allowed", u.Scheme)
|
|
}
|
|
if u.User != nil {
|
|
return errors.New("URLs with embedded credentials are not allowed")
|
|
}
|
|
host := u.Hostname()
|
|
if host == "" {
|
|
return errors.New("URL must have a hostname")
|
|
}
|
|
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
if isPrivateIP(ip) {
|
|
return fmt.Errorf("URL targets private or reserved IP %s", ip)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// defaultTransport lazily builds and memoizes the package's safe
|
|
// transport. Reusing one transport across Fetch calls keeps the
|
|
// keep-alive pool bounded and the FD usage flat under load. AllowLocal
|
|
// is captured on first use — callers that want to flip it after the
|
|
// first Fetch should construct a new Fetcher instead.
|
|
func (f *Fetcher) defaultTransport(timeout time.Duration) *http.Transport {
|
|
f.safeOnce.Do(func() {
|
|
f.safeTransport = newSafeTransport(f.AllowLocal, timeout)
|
|
})
|
|
return f.safeTransport
|
|
}
|
|
|
|
// newSafeTransport returns an *http.Transport whose DialContext resolves
|
|
// each hostname inside the dialer and validates every returned IP. Only
|
|
// IPs that pass isPrivateIP are dialed, and the connection is made to
|
|
// the resolved IP directly (no second lookup) so the validated IP is
|
|
// the dial target.
|
|
//
|
|
// HTTP/HTTPS proxies are deliberately NOT honored: when a proxy is in
|
|
// use the client connects to the proxy host and the target hostname is
|
|
// never resolved by our dialer, which would silently bypass the SSRF
|
|
// guard. Operators who need an outbound proxy can wire their own
|
|
// trusted transport into Fetcher.Transport instead of relying on
|
|
// HTTP_PROXY/HTTPS_PROXY env vars.
|
|
//
|
|
// allowLocal=true (tests only) bypasses the IP check so httptest servers
|
|
// on 127.0.0.1 are reachable.
|
|
func newSafeTransport(allowLocal bool, timeout time.Duration) *http.Transport {
|
|
dialer := &net.Dialer{
|
|
Timeout: timeout,
|
|
KeepAlive: 30 * time.Second,
|
|
}
|
|
resolver := net.DefaultResolver
|
|
return &http.Transport{
|
|
// Proxy intentionally nil — see function docstring.
|
|
Proxy: nil,
|
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("urlimport: split host/port %q: %w", addr, err)
|
|
}
|
|
|
|
// IP literal — already vetted by ValidateURL. Dial directly.
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
if !allowLocal && isPrivateIP(ip) {
|
|
return nil, fmt.Errorf("urlimport: blocked dial to private/reserved IP %s", ip)
|
|
}
|
|
return dialer.DialContext(ctx, network, addr)
|
|
}
|
|
|
|
// Hostname — resolve once, validate every IP, dial the first
|
|
// allowed one. This single resolution is the dial target.
|
|
ips, err := resolver.LookupIP(ctx, "ip", host)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("urlimport: resolve %q: %w", host, err)
|
|
}
|
|
if len(ips) == 0 {
|
|
return nil, fmt.Errorf("urlimport: hostname %q resolved to no addresses", host)
|
|
}
|
|
var lastErr error
|
|
for _, ip := range ips {
|
|
if !allowLocal && isPrivateIP(ip) {
|
|
lastErr = fmt.Errorf("urlimport: %q resolves to private/reserved IP %s", host, ip)
|
|
continue
|
|
}
|
|
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
|
if err == nil {
|
|
return conn, nil
|
|
}
|
|
lastErr = err
|
|
}
|
|
if lastErr != nil {
|
|
return nil, lastErr
|
|
}
|
|
return nil, fmt.Errorf("urlimport: no usable address for %q", host)
|
|
},
|
|
MaxIdleConns: 100,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
}
|
|
}
|
|
|
|
// isPrivateIP returns true for any IP we refuse to fetch from. This
|
|
// includes loopback, RFC1918, IPv4/IPv6 link-local (catches the AWS/GCP/
|
|
// Azure cloud-metadata IP 169.254.169.254), IPv6 unique-local, CGNAT,
|
|
// and the unspecified address.
|
|
func isPrivateIP(ip net.IP) bool {
|
|
if ip.IsLoopback() ||
|
|
ip.IsLinkLocalUnicast() ||
|
|
ip.IsLinkLocalMulticast() ||
|
|
ip.IsUnspecified() ||
|
|
ip.IsPrivate() {
|
|
return true
|
|
}
|
|
// CGNAT (RFC 6598) — not covered by IsPrivate but commonly used on
|
|
// the LAN side of consumer routers / mobile carriers.
|
|
if cgnatCIDR.Contains(ip) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// cgnatCIDR is precomputed at init so isPrivateIP stays allocation-free
|
|
// and concurrency-safe (no shared map writes).
|
|
var cgnatCIDR = func() *net.IPNet {
|
|
_, n, err := net.ParseCIDR("100.64.0.0/10")
|
|
if err != nil {
|
|
panic(fmt.Errorf("urlimport: parse cgnat cidr: %w", err))
|
|
}
|
|
return n
|
|
}()
|
|
|
|
// redactURL hides credentials from log/error output. We already reject
|
|
// credential-bearing URLs at validation time, but Go can hand us an
|
|
// already-stripped url.URL via redirects — defensive double check.
|
|
func redactURL(rawURL string) string {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return rawURL
|
|
}
|
|
return u.Redacted()
|
|
}
|