portage-ucp 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c0a19a01df5cf624b7e08f79b9a8e21a8d9d348ce68469c932233f77e8f65b7
4
- data.tar.gz: ab6342cc1a9045662a0caba73858f2d1fc97f253c2df1593ab1ca629cc870055
3
+ metadata.gz: 37b246e100783b94c0de9a89bdf4093123a45ae87b397202d63fa845847366d1
4
+ data.tar.gz: bfb928d1fd3e5cebff958ecd38cc5c3076ead011b0931cfd497fb2bea70fb64e
5
5
  SHA512:
6
- metadata.gz: 830f760577b2d590337cb38eced77dc7d8a4b102f0e946dd6e8fcfce24078ebb754334615707217ce6b2e9ce1f966b45e830c6dfd14e7dd220515a72644375b8
7
- data.tar.gz: 28139b13e1cc2716f16be0b3bfe35182e9bd48b5508a5e61f4bde1a164e93caf6d9b525d87def91153fea157b789b591fdb179251eb11d377a749b1eb6d1d413
6
+ metadata.gz: fce92b2376dbbce4849a2bda592a3162096f5d472f5d4b222dfe5f8449bdea62bd6ed32ff32db451864080b883c5c50fb7c5165e524dbfe10a58ee04c17e33c5
7
+ data.tar.gz: 9fea9246766b51b2819564732f3af0b4ac0a933a2797c97ad5fe75af88122e7eaeb95681f47631cc1f71d9528876b5c68224fce6ee055474633238c48c0a97d4
data/CHANGELOG.md CHANGED
@@ -4,6 +4,75 @@ All notable changes to this project are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
5
5
  pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [0.11.0] - 2026-09-29
10
+
11
+ - `Portage::Ucp::Check` (and `portage-ucp-check`) now follows a `<link rel="ucp" href="...">`
12
+ manifest pointer in the homepage when `/.well-known/ucp` is missing, the same fallback
13
+ `portage buy` uses. The report adds `manifest_url` when the manifest came from that link.
14
+
15
+ - Documentation only, no code change. Fixes two wrong comments. `Authenticator`
16
+ pointed at a `mutating_only:` option that `Mcp::Server.build` doesn't have:
17
+ it only authenticates tools whose adapter method takes `idempotency_key:`,
18
+ and no option changes that. `Manifest#to_h` read as if this manifest reports
19
+ `"version": "2026-08-25"`. That is what live Shopify stores serve. This
20
+ manifest reports `UCP_VERSION`, `"2026-04-08"`.
21
+
22
+ ## [0.10.0] - 2026-09-25
23
+
24
+ - **Proxy support** (`docs/plans/proxy-support.md` Phases 1 and 3).
25
+ `Support::Connection.start(uri, route:, proxy: ProxyConfig.current, ...)`
26
+ replaces every raw `Net::HTTP.start` call site. It resolves
27
+ `HTTPS_PROXY`/`https_proxy` for https targets (Net::HTTP's own `:ENV`
28
+ mode never read them) and honors `NO_PROXY`. It also supports
29
+ `ProxyConfig` routes and named chains: `:forward` proxies (natively, or
30
+ through a hand-rolled CONNECT tunnel when `proxy_headers` are set or
31
+ hops are chained), `:gateway` mode, `ca_file` and timeout overrides.
32
+ A hop failure raises `Portage::Ucp::ProxyError` naming the hop and the
33
+ redacted proxy host. `ProxyConfig::Profile` refuses `Authorization`,
34
+ `User-Agent`, `X-Shopify-*-Access-Token` and `X-Payment-Token` in
35
+ proxy/forward headers. `Support::HttpClient#json_request` takes
36
+ `route:` (default `:platform`); `Check` uses `:probe` and
37
+ `Support::TokenExchange` uses `:payment`.
38
+ - `Rack::ForwardedRequest`, a fail-closed helper that trusts
39
+ `X-Forwarded-*`/`Forwarded` only from a configured `trusted_proxies`
40
+ list, resolves the right-most untrusted hop as the client IP, and lets
41
+ `X-Forwarded-Host` replace an endpoint's origin only for a trusted peer
42
+ and an allowlisted host. `Rack::WebhookEndpoint` uses it.
43
+ - `Support::PassthroughContext`, a fiber-local scope that carries an
44
+ inbound request's allowlisted, trusted-peer-only headers and
45
+ `Forwarded`/`X-Forwarded-For` chain onto the outbound
46
+ `Support::Connection` calls made while serving it.
47
+ - Adapter and CLI gems that call `Support::Connection` need `~> 0.10`.
48
+
49
+ - `Support::TransactionLog#reserve`/`#complete` accept a fixed allowlist of
50
+ optional attributes (`OPTIONAL_ATTRIBUTES`: `settled_by`, `handoff_reason`,
51
+ `store_url`, `expires_at`, `resolution`, `counts_toward_caps`) beyond
52
+ their existing keywords, raising `ArgumentError` on anything else — the
53
+ extension point `docs/plans/handoff-reconcile.md`'s Phase 1/2 use to
54
+ record and settle a shopper-completed hand-off as a transaction record,
55
+ not a parallel path. `#completed_since` now excludes
56
+ `counts_toward_caps: false` records (Phase 2's `warn` spend mode); a
57
+ record with neither field set reads exactly as before. No change to any
58
+ existing caller.
59
+ - Fix: `Support::HttpClient#json_request` opened every request with no
60
+ `open_timeout`/`read_timeout` of its own, so a hung upstream fell back to
61
+ Net::HTTP's own 60s defaults on both — a full minute an agent loop could
62
+ be stuck mid-checkout before anything reacted. It now defaults to a 5s
63
+ open / 30s read timeout (mirroring `Check#get`'s own explicit precedent),
64
+ overridable per call via `open_timeout:`/`read_timeout:` keywords.
65
+ `Support::Retry#retryable_error?` now also treats `Net::OpenTimeout` and
66
+ `Net::ReadTimeout` as retryable — neither carries a `status`, but both are
67
+ exactly the "the upstream didn't do the work, try again" case the module
68
+ exists for.
69
+ - Documentation only, no code change. Fixes the README's "Usage" snippet:
70
+ it called `server.start` on the `MCP::Server` `Mcp::Server.build` returns,
71
+ but that class (mcp gem 0.25.0) has no `#start` — only
72
+ `MCP::Server::Transports::StdioTransport#open` reads stdio frames. Every
73
+ bundled adapter's `exe/` had the same bug; this just fixes the README to
74
+ match.
75
+
7
76
  ## [0.9.0] - 2026-09-23
8
77
 
9
78
  - New `Support::OfferRanking` and `Support::Escalation`: the offer-ranking
data/README.md CHANGED
@@ -19,7 +19,7 @@ in this gem.
19
19
  | `Portage::Ucp::Adapter` | The contract your backend implements — override only the catalog/cart/checkout/order/identity methods you support; the rest stay unadvertised. |
20
20
  | `Portage::Ucp::CapabilityRegistry` | Figures out which capabilities an `Adapter` actually backs. |
21
21
  | `Portage::Ucp::Dispatcher` | Routes a capability+action call to the right `Adapter` method. |
22
- | `Portage::Ucp::Mcp::Server` | Wraps an `Adapter` as an MCP server — one `MCP::Tool` per advertised action, stdio or Streamable HTTP. |
22
+ | `Portage::Ucp::Mcp::Server` | Wraps an `Adapter` as an MCP server — one `MCP::Tool` per advertised action; serve it over stdio or Streamable HTTP with the `mcp` gem's transports. |
23
23
  | `Portage::Ucp::Manifest` | Builds the signed `/.well-known/ucp` discovery document. |
24
24
  | `Portage::Ucp::Rack::ManifestEndpoint` | Serves that manifest over Rack. |
25
25
  | `Portage::Ucp::Rack::WebhookEndpoint` | HMAC-verified inbound order-lifecycle webhooks. |
@@ -32,7 +32,7 @@ in this gem.
32
32
  | `Portage::Ucp::PolicyGuard` / `Portage::Ucp::Policy` | Per-transaction/rolling/velocity caps and a merchant allowlist, checked before `complete_checkout` dispatch; configured via `portage-cli`'s `portage policy show/set`. |
33
33
  | `Portage::Ucp::Support::OfferRanking` / `Portage::Ucp::Support::Escalation` | The offer-ranking rule (buyable, then priced, then cheapest, ties stable) and the escalation rule (`requires_escalation`, then a mismatch). `portage-ucp-decision` wraps both as typed verdicts; `portage-cli` calls them directly. |
34
34
  | `Portage::Ucp::PaymentEnrollmentGuard` | Validates every `create_payment_enrollment`/`get_payment_enrollment` result an `Adapter` returns — `status` must be `"pending"` (with a `setup_url`, no `payment_token`) or `"complete"` (with a `payment_token`, no `setup_url`). Runs automatically in `Dispatcher#call`. |
35
- | `Portage::Ucp::Ap2::PaymentMandate` / `Portage::Ucp::Ap2::MandateGuard` | A typed shape for an AP2 payment mandate, and shape-only validation (required fields + expiry — not cryptographic verification) run automatically on any `mandate:` argument passed through `Dispatcher#call`. |
35
+ | `Portage::Ucp::Ap2::PaymentMandate` / `Portage::Ucp::Ap2::MandateGuard` | A typed shape for an AP2 payment mandate, and validation (required fields + expiry, plus signature verification when `mandate_trusted_keys` is configured) run automatically on any `mandate:` argument passed through `Dispatcher#call`. |
36
36
 
37
37
  Security defaults are all locked down, not permissive-by-omission —
38
38
  `UnconfiguredAuthenticator` rejects every mutating call until you configure a real
@@ -73,7 +73,7 @@ Portage::Ucp.configure do |config|
73
73
  end
74
74
 
75
75
  server = Portage::Ucp::Mcp::Server.build(adapter: MyAdapter.new)
76
- server.start
76
+ MCP::Server::Transports::StdioTransport.new(server).open
77
77
  ```
78
78
 
79
79
  See the root README's [Usage](https://github.com/tomtom87/Portage#usage)
@@ -4,10 +4,11 @@ module Portage
4
4
  # tool call, return an auth context (any truthy value) or raise
5
5
  # Portage::Ucp::AuthenticationError. There is deliberately no default that
6
6
  # allows anonymous mutation — an unconfigured server rejects every
7
- # mutating capability call (see UNCONFIGURED below). Read-only catalog
8
- # calls MAY be left open at the consumer's explicit choice by not
9
- # requiring authentication for those specific actions (see
10
- # Mcp::Server.build's `mutating_only:`).
7
+ # mutating capability call (see UnconfiguredAuthenticator below).
8
+ # Mcp::Server.build calls it only for mutating tools: those whose
9
+ # adapter method takes `idempotency_key:`. Every other tool (catalog
10
+ # search, product, cart, checkout and order lookups) never reaches it,
11
+ # and Server.build has no option to change that.
11
12
  class Authenticator
12
13
  def call(_server_context)
13
14
  raise NotImplementedError, "#{self.class} must implement #call"
@@ -1,13 +1,16 @@
1
1
  require "net/http"
2
2
  require "uri"
3
3
  require "json"
4
+ require_relative "support/connection"
4
5
 
5
6
  module Portage
6
7
  module Ucp
7
8
  # `portage-ucp-check <url>` — point it at any storefront and find out what
8
9
  # portage-ucp can do with it. Checks for a native `/.well-known/ucp`
9
10
  # manifest first (the store may already speak UCP without this gem, see
10
- # README's "Why /.well-known/ucp?"); if there isn't one, detects the
11
+ # README's "Why /.well-known/ucp?"), then a `<link rel="ucp" href=...>`
12
+ # manifest pointer in the homepage (the same fallback `portage buy`
13
+ # follows); if there isn't one, detects the
11
14
  # commerce platform from the page itself (see Resolver) and names
12
15
  # the matching portage-ucp-<adapter> gem — probing it live when that
13
16
  # adapter's env vars are already set, so "best match" means a
@@ -29,6 +32,10 @@ module Portage
29
32
  return { url: @uri.to_s, native_ucp: manifest } if manifest
30
33
 
31
34
  body, headers = fetch_homepage
35
+ linked = manifest_link(body)
36
+ manifest = linked && fetch_json(linked)
37
+ return { url: @uri.to_s, native_ucp: manifest, manifest_url: linked.to_s } if manifest
38
+
32
39
  platform = Resolver.detect_platform(body, headers)
33
40
 
34
41
  report = { url: @uri.to_s, native_ucp: nil, platform: platform&.name, recommended_gem: platform&.gem }
@@ -41,7 +48,11 @@ module Portage
41
48
  def fetch_manifest
42
49
  manifest_uri = @uri.dup
43
50
  manifest_uri.path = MANIFEST_PATH
44
- response = get(manifest_uri)
51
+ fetch_json(manifest_uri)
52
+ end
53
+
54
+ def fetch_json(uri)
55
+ response = get(uri)
45
56
  return nil unless response.is_a?(Net::HTTPSuccess)
46
57
 
47
58
  JSON.parse(response.body)
@@ -58,6 +69,18 @@ module Portage
58
69
  [nil, {}]
59
70
  end
60
71
 
72
+ # `<link rel="ucp" href="...">` in either attribute order, resolved
73
+ # against the store URL.
74
+ def manifest_link(body)
75
+ return nil unless body
76
+
77
+ match = body.match(/<link[^>]+rel=["']ucp["'][^>]+href=["']([^"']+)["']/i) ||
78
+ body.match(/<link[^>]+href=["']([^"']+)["'][^>]+rel=["']ucp["']/i)
79
+ match && URI.join(@uri, match[1])
80
+ rescue URI::Error
81
+ nil
82
+ end
83
+
61
84
  def probe(platform)
62
85
  env = Resolver.env_for(platform)
63
86
  missing = Resolver.missing_env(platform, env)
@@ -79,8 +102,7 @@ module Portage
79
102
  def get(uri, limit = REDIRECT_LIMIT)
80
103
  raise Portage::Ucp::Error, "too many redirects" if limit.zero?
81
104
 
82
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
83
- open_timeout: 5, read_timeout: 5) do |http|
105
+ response = Support::Connection.start(uri, route: :probe, open_timeout: 5, read_timeout: 5) do |http|
84
106
  http.get(uri.request_uri, { "User-Agent" => "portage-ucp-check" })
85
107
  end
86
108
 
@@ -129,10 +129,10 @@ module Portage
129
129
  # or status HTTP call itself fails.
130
130
  # @return [Hash] `{approved: true}` on approval.
131
131
  def confirm!(amount:, currency:, merchant:, idempotency_key:)
132
- json_request(Net::HTTP::Post, @confirm_url,
133
- body: { amount: amount, currency: currency, merchant: merchant,
134
- idempotency_key: idempotency_key },
135
- headers: @headers)
132
+ json_request(Net::HTTP::Post, @confirm_url, route: :notify,
133
+ body: { amount: amount, currency: currency, merchant: merchant,
134
+ idempotency_key: idempotency_key },
135
+ headers: @headers)
136
136
 
137
137
  status = @wait ? @wait.call(idempotency_key) : poll(idempotency_key)
138
138
  return { approved: true } if status == "approved"
@@ -146,7 +146,7 @@ module Portage
146
146
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout_seconds
147
147
 
148
148
  loop do
149
- response = json_request(Net::HTTP::Get, status_url_for(idempotency_key), headers: @headers)
149
+ response = json_request(Net::HTTP::Get, status_url_for(idempotency_key), route: :notify, headers: @headers)
150
150
  return response["status"] if %w[approved denied].include?(response["status"])
151
151
  return "timeout" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
152
152
 
@@ -87,5 +87,40 @@ module Portage
87
87
  @decision = decision
88
88
  end
89
89
  end
90
+
91
+ # Raised by Support::Connection (docs/plans/proxy-support.md Phase 1)
92
+ # when any hop of a proxy connection fails — a plain-forward/gateway
93
+ # dial that can't reach its proxy at all, or (most commonly) a
94
+ # hand-rolled CONNECT tunnel hop answering anything but 200. Named by
95
+ # 1-indexed hop position (never 0-indexed — "hop 2" means the second
96
+ # proxy a request passes through, matching how a human would describe a
97
+ # chain) and the *redacted* proxy host (`host:port`, or
98
+ # `http://***@host:port` when the profile carried credentials) — never
99
+ # the real credentials, same posture as `doctor`'s proxy_finding in
100
+ # portage-cli. `status` is the CONNECT response's HTTP status when the
101
+ # hop answered at all (e.g. 407 for "proxy authentication required"),
102
+ # or nil when the hop never answered (DNS failure, connection refused).
103
+ class ProxyError < Error
104
+ attr_reader :hop_index, :status
105
+
106
+ def initialize(hop_index:, host:, status: nil, detail: nil)
107
+ @hop_index = hop_index
108
+ @status = status
109
+ super(build_message(host, detail))
110
+ end
111
+
112
+ private
113
+
114
+ def build_message(host, detail)
115
+ redacted = Support::Connection.redact(host)
116
+ outcome = if status
117
+ reason = Support::Connection::REASONS[status]
118
+ "CONNECT failed with #{status}#{" (#{reason})" if reason}"
119
+ else
120
+ "CONNECT failed#{" (#{detail})" if detail}"
121
+ end
122
+ "hop #{hop_index} (#{redacted}): #{outcome}"
123
+ end
124
+ end
90
125
  end
91
126
  end
@@ -32,10 +32,11 @@ module Portage
32
32
  end
33
33
 
34
34
  # Nests everything under a "ucp" envelope, and keys `capabilities` by
35
- # capability name (matching what live Shopify UCP rollouts — Casper,
36
- # Allbirds, Glossier, and 34+ others — actually serve as of manifest
37
- # "version": "2026-08-25"; see docs/well-known-ucp.md and
38
- # portage-ucp-client's Client.discover, which this shape now matches).
35
+ # capability name. That is the shape live Shopify UCP rollouts (Casper,
36
+ # Allbirds, Glossier, and 34+ others) serve under their own manifest
37
+ # "version": "2026-08-25", and the shape portage-ucp-client's
38
+ # Client.discover reads. Only the shape is borrowed: `version` here is
39
+ # still UCP_VERSION ("2026-04-08"), the spec revision this gem targets.
39
40
  def to_h
40
41
  ucp = {
41
42
  version: UCP_VERSION,
@@ -0,0 +1,258 @@
1
+ require "ipaddr"
2
+ require "rack"
3
+
4
+ module Portage
5
+ module Ucp
6
+ module Rack
7
+ # Phase 3 of docs/plans/proxy-support.md: the one seam every inbound
8
+ # Rack endpoint (WebMCP's CallEndpoint, Rack::WebhookEndpoint, and
9
+ # whatever HTTP transport fronts Mcp::Server) uses to make sense of
10
+ # `X-Forwarded-*`/`Forwarded` (RFC 7239) headers a reverse proxy sits
11
+ # in front of them adds.
12
+ #
13
+ # Fail-closed by construction: with no `trusted_proxies` configured
14
+ # (the default — `[]`), or when the immediate socket peer isn't in
15
+ # that list, every method here answers exactly as if no forwarded
16
+ # headers were present at all — behavior stays unchanged from before
17
+ # this class existed. Only once the peer itself is a trusted proxy do
18
+ # `X-Forwarded-*`/`Forwarded` get any say in the resolved client IP,
19
+ # scheme, or host.
20
+ #
21
+ # A consumer sets `trusted_proxies:` wherever it builds one of the
22
+ # endpoints above — e.g. `CallEndpoint.new(catalog: catalog,
23
+ # trusted_proxies: ["10.0.0.0/8"])` — naming the CIDR(s) of its own
24
+ # load balancer/reverse proxy, never a wildcard or the public
25
+ # Internet.
26
+ class ForwardedRequest
27
+ FOR_HEADER = "HTTP_X_FORWARDED_FOR".freeze
28
+ HOST_HEADER = "HTTP_X_FORWARDED_HOST".freeze
29
+ PROTO_HEADER = "HTTP_X_FORWARDED_PROTO".freeze
30
+ FORWARDED_HEADER = "HTTP_FORWARDED".freeze
31
+
32
+ # @raise [Portage::Ucp::Support::ProxyConfig::ConfigError] naming the
33
+ # offending header, same posture as every other protected-header
34
+ # check in the proxy-support plan — a passthrough list can never
35
+ # contain Authorization/User-Agent/X-Shopify-*-Access-Token/
36
+ # X-Payment-Token, checked once here at config-resolution time
37
+ # rather than silently dropped per-request later.
38
+ def self.validate_passthrough!(header_names)
39
+ offender = Array(header_names).find { |name| Support::ProxyConfig.protected_header?(name) }
40
+ return unless offender
41
+
42
+ raise Support::ProxyConfig::ConfigError,
43
+ "#{offender} is a protected header and cannot be set via passthrough.headers"
44
+ end
45
+
46
+ # @param request [Rack::Request]
47
+ # @param trusted_proxies [Array<String>] CIDR blocks (or bare IPs,
48
+ # which IPAddr treats as a /32 or /128) naming the reverse proxies
49
+ # allowed to set forwarding headers. Empty (the default) means
50
+ # fail-closed: nothing is ever trusted.
51
+ def initialize(request, trusted_proxies: [])
52
+ @request = request
53
+ @trusted_proxies = Array(trusted_proxies).filter_map { |cidr| safe_ipaddr(cidr) }
54
+ end
55
+
56
+ # @return [Boolean] whether the immediate socket peer is one of the
57
+ # configured `trusted_proxies`. Reads `REMOTE_ADDR` straight off
58
+ # the env (`Rack::Request#get_header`, not `#ip`) rather than
59
+ # `request.ip` — Rack::Request#ip applies its *own*, separate
60
+ # `Rack::Request.ip_filter` (a process-wide default that already
61
+ # trusts RFC 1918/loopback ranges out of the box, independent of
62
+ # whatever this class is configured with) and would otherwise let
63
+ # Rack itself decide a header is trustworthy before this class
64
+ # ever gets a say. Fail-closed has to mean fail-closed regardless
65
+ # of Rack's own defaults.
66
+ def peer_trusted?
67
+ return @peer_trusted if defined?(@peer_trusted)
68
+
69
+ @peer_trusted = trusted_ip?(raw_remote_addr)
70
+ end
71
+
72
+ # @return [String] the resolved client IP: standard reverse-proxy
73
+ # chain parsing, walking the `X-Forwarded-For`/`Forwarded` chain
74
+ # from the right and skipping any entry that is itself a trusted
75
+ # proxy — the first untrusted entry from the right is the real
76
+ # client. Falls back to the plain socket peer when the peer isn't
77
+ # trusted, no forwarding header is present, or every entry in the
78
+ # chain is itself a trusted proxy (nothing untrusted to report).
79
+ def client_ip
80
+ return strip_port(raw_remote_addr) unless peer_trusted?
81
+
82
+ chain = forwarded_for_chain
83
+ return strip_port(raw_remote_addr) if chain.empty?
84
+
85
+ chain.reverse_each { |candidate| return candidate unless trusted_ip?(candidate) }
86
+ chain.first
87
+ end
88
+
89
+ # @return [String] "http"/"https" — from `X-Forwarded-Proto`/
90
+ # `Forwarded;proto=` only when the peer is trusted, the raw
91
+ # connection scheme otherwise (never Rack::Request#scheme, which
92
+ # is itself forwarded-header-aware via Rack's own ip_filter — see
93
+ # #peer_trusted?).
94
+ def scheme
95
+ return raw_scheme unless peer_trusted?
96
+
97
+ header_value(PROTO_HEADER) || forwarded_pair("proto") || raw_scheme
98
+ end
99
+
100
+ # @return [String] "host[:port]" — from `X-Forwarded-Host`/
101
+ # `Forwarded;host=` only when the peer is trusted, the raw `Host`
102
+ # header otherwise (never Rack::Request#host_with_port, same
103
+ # reason as #scheme above).
104
+ def host
105
+ return raw_host unless peer_trusted?
106
+
107
+ header_value(HOST_HEADER) || forwarded_pair("host") || raw_host
108
+ end
109
+
110
+ # @return [Boolean] true only when the peer is trusted AND the
111
+ # forwarded host is itself in `allowed` — the gate that keeps a
112
+ # spoofed `X-Forwarded-Host` from ever widening what "own origin"
113
+ # means unless a consumer explicitly opted that host in.
114
+ def forwarded_host_allowed?(allowed)
115
+ return false unless peer_trusted?
116
+
117
+ forwarded_host = header_value(HOST_HEADER) || forwarded_pair("host")
118
+ return false if forwarded_host.nil?
119
+
120
+ Array(allowed).map(&:to_s).include?(forwarded_host)
121
+ end
122
+
123
+ # @return [String] what a same-origin check should compare against
124
+ # for "the endpoint's own origin" — the raw scheme/host unchanged,
125
+ # unless `forwarded_host_allowed?(forwarded_host_allowed)` says
126
+ # the forwarded host is explicitly trusted for this purpose, in
127
+ # which case the forwarded scheme/host replace it. Never widens
128
+ # `allowed_origins` itself — only ever changes what "own origin"
129
+ # resolves to.
130
+ def own_origin(forwarded_host_allowed: [])
131
+ return "#{raw_scheme}://#{raw_host}" unless forwarded_host_allowed?(forwarded_host_allowed)
132
+
133
+ "#{scheme}://#{host}"
134
+ end
135
+
136
+ # @param header_names [Array<String>] allowlisted inbound header
137
+ # names (already validated with .validate_passthrough! at
138
+ # config-resolution time). Returns {} from an untrusted peer, no
139
+ # matter what the caller asks for.
140
+ # @return [Hash{String=>String}]
141
+ def passthrough_headers(header_names)
142
+ return {} unless peer_trusted?
143
+
144
+ Array(header_names).each_with_object({}) do |name, out|
145
+ value = @request.get_header(rack_env_key(name))
146
+ out[name.to_s] = value if value
147
+ end
148
+ end
149
+
150
+ private
151
+
152
+ # The literal `REMOTE_ADDR`/`HTTP_HOST`/scheme env entries, bypassing
153
+ # Rack::Request's own forwarded-aware `#ip`/`#host`/`#scheme` (see
154
+ # #peer_trusted?'s comment) — this class makes its own trust
155
+ # decision from `trusted_proxies` alone, never Rack's separate
156
+ # `Rack::Request.ip_filter` default.
157
+ def raw_remote_addr
158
+ @request.get_header("REMOTE_ADDR").to_s
159
+ end
160
+
161
+ def raw_host
162
+ host_port = @request.get_header("HTTP_HOST") ||
163
+ "#{@request.get_header('SERVER_NAME')}:#{@request.get_header('SERVER_PORT')}"
164
+ strip_default_port(host_port)
165
+ end
166
+
167
+ def raw_scheme
168
+ return "https" if @request.get_header("HTTPS") == "on"
169
+
170
+ @request.get_header("rack.url_scheme") || "http"
171
+ end
172
+
173
+ # Mirrors Rack::Request#host_with_port's own "omit :80/:443 for the
174
+ # matching scheme" behavior, so #own_origin's unforwarded fallback
175
+ # matches what a plain, un-proxied request's origin has always
176
+ # looked like.
177
+ def strip_default_port(host_port)
178
+ host, port = host_port.to_s.split(":", 2)
179
+ return host_port if port.nil?
180
+ return host if raw_scheme == "http" && port == "80"
181
+ return host if raw_scheme == "https" && port == "443"
182
+
183
+ host_port
184
+ end
185
+
186
+ def rack_env_key(name)
187
+ "HTTP_#{name.to_s.upcase.tr('-', '_')}"
188
+ end
189
+
190
+ def header_value(env_key)
191
+ value = @request.get_header(env_key)
192
+ value.nil? || value.empty? ? nil : value
193
+ end
194
+
195
+ def forwarded_for_chain
196
+ xff = header_value(FOR_HEADER)
197
+ return xff.split(",").map { |entry| strip_port(entry) }.reject(&:empty?) if xff
198
+
199
+ forwarded_pairs("for").map { |value| strip_port(value) }
200
+ end
201
+
202
+ # Extracts every `for=` value, in order (oldest hop first, per RFC
203
+ # 7239 — each proxy appends, never prepends).
204
+ def forwarded_pairs(key)
205
+ raw = header_value(FORWARDED_HEADER)
206
+ return [] unless raw
207
+
208
+ raw.split(",").filter_map { |element| pair_from(element, key) }
209
+ end
210
+
211
+ # The single most-recent (right-most) `Forwarded` element's value
212
+ # for `key` — used for scheme/host, which only the immediate
213
+ # (trusted) hop's own element should set.
214
+ def forwarded_pair(key)
215
+ raw = header_value(FORWARDED_HEADER)
216
+ return nil unless raw
217
+
218
+ raw.split(",").filter_map { |element| pair_from(element, key) }.last
219
+ end
220
+
221
+ def pair_from(element, key)
222
+ match = element.match(/#{key}="?([^;,"]+)"?/i)
223
+ return nil unless match
224
+
225
+ match[1].strip.sub(/\A\[/, "").sub(/\]\z/, "")
226
+ end
227
+
228
+ def trusted_ip?(ip_string)
229
+ return false if @trusted_proxies.empty?
230
+
231
+ addr = safe_ipaddr(strip_port(ip_string))
232
+ return false unless addr
233
+
234
+ @trusted_proxies.any? { |cidr| cidr.include?(addr) }
235
+ end
236
+
237
+ def safe_ipaddr(value)
238
+ IPAddr.new(value.to_s)
239
+ rescue IPAddr::Error
240
+ nil
241
+ end
242
+
243
+ # Handles a bracketed IPv6 literal (`[::1]:8080`), a plain
244
+ # `host:port` pair, and a bare address (IPv4, or IPv6 with no
245
+ # port) — an unbracketed IPv6 address has more than one colon, so
246
+ # only a single colon is ever treated as a port separator.
247
+ def strip_port(value)
248
+ value = value.to_s.strip
249
+ return value if value.empty?
250
+ return value[/\A\[(.*)\]/, 1] || value if value.start_with?("[")
251
+ return value.split(":").first if value.count(":") == 1
252
+
253
+ value
254
+ end
255
+ end
256
+ end
257
+ end
258
+ end
@@ -1,6 +1,7 @@
1
1
  require "json"
2
2
  require "openssl"
3
3
  require "rack"
4
+ require_relative "forwarded_request"
4
5
 
5
6
  module Portage
6
7
  module Ucp
@@ -20,42 +21,77 @@ module Portage
20
21
  # a logger threaded to it, not a reason to invent a second config
21
22
  # option: `logger:` already exists on Portage::Ucp.configuration and
22
23
  # every other collaborator that logs takes it the same way.
24
+ #
25
+ # Phase 3 of docs/plans/proxy-support.md: `trusted_proxies:` lets a
26
+ # consumer behind a reverse proxy get the real client IP into its own
27
+ # logs/`on_order_event` handling (`ForwardedRequest#client_ip`, fail-
28
+ # closed with no config change otherwise) and, when
29
+ # `passthrough_headers:` is also set, forwards that request's
30
+ # allowlisted headers (trusted peer only) to any outbound call
31
+ # `on_order_event` makes via Support::Connection, through the same
32
+ # fiber-local Support::PassthroughContext CallEndpoint uses — cleared
33
+ # again once `on_order_event` returns.
23
34
  class WebhookEndpoint
35
+ # @param trusted_proxies [Array<String>] CIDRs of the consumer's own
36
+ # reverse proxy/load balancer — see ForwardedRequest.
37
+ # @param passthrough_headers [Array<String>] inbound header names to
38
+ # forward to outbound calls made from `on_order_event`, from a
39
+ # trusted peer only. Never a protected header (raises at
40
+ # construction time — see ForwardedRequest.validate_passthrough!).
41
+ # @param passthrough_forwarded ["append", "replace", "drop"] see
42
+ # Support::PassthroughContext.
24
43
  def initialize(secret:, on_order_event:, signature_header: "HTTP_X_UCP_SIGNATURE",
25
- logger: Portage::Ucp.configuration.logger)
44
+ logger: Portage::Ucp.configuration.logger, trusted_proxies: [],
45
+ passthrough_headers: [], passthrough_forwarded: "drop")
26
46
  @secret = secret
27
47
  @on_order_event = on_order_event
28
48
  @signature_header = signature_header
29
49
  @logger = logger
50
+ @trusted_proxies = trusted_proxies
51
+ @passthrough_headers = passthrough_headers
52
+ @passthrough_forwarded = passthrough_forwarded
53
+ ForwardedRequest.validate_passthrough!(@passthrough_headers)
30
54
  end
31
55
 
32
56
  def call(env)
33
57
  request = ::Rack::Request.new(env)
34
58
  return respond(404, error: "not_found") unless request.post?
35
59
 
60
+ forwarded = ForwardedRequest.new(request, trusted_proxies: @trusted_proxies)
36
61
  body = request.body.read
37
62
  unless valid_signature?(body, env[@signature_header])
38
- Portage::Ucp::Observability.log(@logger, "order_webhook_rejected", reason: "invalid_signature")
63
+ Portage::Ucp::Observability.log(@logger, "order_webhook_rejected", reason: "invalid_signature",
64
+ client_ip: forwarded.client_ip)
39
65
  return respond(401, error: "invalid_signature")
40
66
  end
41
67
 
42
- handle_order_event(body)
68
+ handle_order_event(body, forwarded)
43
69
  end
44
70
 
45
71
  private
46
72
 
47
- def handle_order_event(body)
73
+ def handle_order_event(body, forwarded)
48
74
  payload = JSON.parse(body, symbolize_names: true)
49
75
  order = Portage::Ucp::Order.new(**payload)
50
76
  Portage::Ucp::Observability.log(@logger, "order_webhook_received", order_id: order.id,
51
- checkout_id: order.checkout_id)
52
- @on_order_event.call(order)
77
+ checkout_id: order.checkout_id,
78
+ client_ip: forwarded.client_ip)
79
+ with_passthrough(forwarded) { @on_order_event.call(order) }
53
80
  respond(200, ok: true)
54
81
  rescue JSON::ParserError, ArgumentError
55
- Portage::Ucp::Observability.log(@logger, "order_webhook_rejected", reason: "bad_request")
82
+ Portage::Ucp::Observability.log(@logger, "order_webhook_rejected", reason: "bad_request",
83
+ client_ip: forwarded.client_ip)
56
84
  respond(400, error: "bad_request")
57
85
  end
58
86
 
87
+ def with_passthrough(forwarded, &)
88
+ return yield unless forwarded.peer_trusted? && @passthrough_headers.any?
89
+
90
+ headers = forwarded.passthrough_headers(@passthrough_headers)
91
+ Portage::Ucp::Support::PassthroughContext.with(headers: headers, forwarded: @passthrough_forwarded,
92
+ chain_entry: forwarded.client_ip, &)
93
+ end
94
+
59
95
  def valid_signature?(body, signature)
60
96
  return false unless signature
61
97