portage-ucp 0.9.0 → 0.10.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 +4 -4
- data/CHANGELOG.md +54 -0
- data/README.md +1 -1
- data/lib/portage/ucp/check.rb +2 -2
- data/lib/portage/ucp/confirmer.rb +5 -5
- data/lib/portage/ucp/errors.rb +35 -0
- data/lib/portage/ucp/rack/forwarded_request.rb +258 -0
- data/lib/portage/ucp/rack/webhook_endpoint.rb +43 -7
- data/lib/portage/ucp/support/connection.rb +475 -0
- data/lib/portage/ucp/support/http_client.rb +26 -2
- data/lib/portage/ucp/support/passthrough_context.rb +63 -0
- data/lib/portage/ucp/support/proxy_config.rb +225 -0
- data/lib/portage/ucp/support/retry.rb +11 -3
- data/lib/portage/ucp/support/token_exchange.rb +2 -1
- data/lib/portage/ucp/support/transaction_log.rb +67 -4
- data/lib/portage/ucp/version.rb +1 -1
- data/lib/portage/ucp.rb +6 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7888232b409966ef29fae287d17d6af418dd676e12ad53a7ef7d30d7178f6ae6
|
|
4
|
+
data.tar.gz: e967bbb103d6911ca4ed8d4fb1204687cd8a2c7a51e3ab7675db5420b7eb0623
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3f78f566ac2ee41da063ae9a96a658707bf481f8f9ee791e288fc2d78ce0325ed147c2423a137b06350b3ddb5f64f09c17a5ba6967894e7795ef72c90d6f1384
|
|
7
|
+
data.tar.gz: 61a0ec7f01125fb8da2e0eb74fdc03b50d4fc94e38a3863e8366b1932cc871ab3787253d3199845a4a18a1902245fb7b2d3ca336b2bd896a82c4ce011d187f5b
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,60 @@ 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
|
+
## [0.10.0] - 2026-09-25
|
|
8
|
+
|
|
9
|
+
- **Proxy support** (`docs/plans/proxy-support.md` Phases 1 and 3).
|
|
10
|
+
`Support::Connection.start(uri, route:, proxy: ProxyConfig.current, ...)`
|
|
11
|
+
replaces every raw `Net::HTTP.start` call site. It resolves
|
|
12
|
+
`HTTPS_PROXY`/`https_proxy` for https targets (Net::HTTP's own `:ENV`
|
|
13
|
+
mode never read them) and honors `NO_PROXY`. It also supports
|
|
14
|
+
`ProxyConfig` routes and named chains: `:forward` proxies (natively, or
|
|
15
|
+
through a hand-rolled CONNECT tunnel when `proxy_headers` are set or
|
|
16
|
+
hops are chained), `:gateway` mode, `ca_file` and timeout overrides.
|
|
17
|
+
A hop failure raises `Portage::Ucp::ProxyError` naming the hop and the
|
|
18
|
+
redacted proxy host. `ProxyConfig::Profile` refuses `Authorization`,
|
|
19
|
+
`User-Agent`, `X-Shopify-*-Access-Token` and `X-Payment-Token` in
|
|
20
|
+
proxy/forward headers. `Support::HttpClient#json_request` takes
|
|
21
|
+
`route:` (default `:platform`); `Check` uses `:probe` and
|
|
22
|
+
`Support::TokenExchange` uses `:payment`.
|
|
23
|
+
- `Rack::ForwardedRequest`, a fail-closed helper that trusts
|
|
24
|
+
`X-Forwarded-*`/`Forwarded` only from a configured `trusted_proxies`
|
|
25
|
+
list, resolves the right-most untrusted hop as the client IP, and lets
|
|
26
|
+
`X-Forwarded-Host` replace an endpoint's origin only for a trusted peer
|
|
27
|
+
and an allowlisted host. `Rack::WebhookEndpoint` uses it.
|
|
28
|
+
- `Support::PassthroughContext`, a fiber-local scope that carries an
|
|
29
|
+
inbound request's allowlisted, trusted-peer-only headers and
|
|
30
|
+
`Forwarded`/`X-Forwarded-For` chain onto the outbound
|
|
31
|
+
`Support::Connection` calls made while serving it.
|
|
32
|
+
- Adapter and CLI gems that call `Support::Connection` need `~> 0.10`.
|
|
33
|
+
|
|
34
|
+
- `Support::TransactionLog#reserve`/`#complete` accept a fixed allowlist of
|
|
35
|
+
optional attributes (`OPTIONAL_ATTRIBUTES`: `settled_by`, `handoff_reason`,
|
|
36
|
+
`store_url`, `expires_at`, `resolution`, `counts_toward_caps`) beyond
|
|
37
|
+
their existing keywords, raising `ArgumentError` on anything else — the
|
|
38
|
+
extension point `docs/plans/handoff-reconcile.md`'s Phase 1/2 use to
|
|
39
|
+
record and settle a shopper-completed hand-off as a transaction record,
|
|
40
|
+
not a parallel path. `#completed_since` now excludes
|
|
41
|
+
`counts_toward_caps: false` records (Phase 2's `warn` spend mode); a
|
|
42
|
+
record with neither field set reads exactly as before. No change to any
|
|
43
|
+
existing caller.
|
|
44
|
+
- Fix: `Support::HttpClient#json_request` opened every request with no
|
|
45
|
+
`open_timeout`/`read_timeout` of its own, so a hung upstream fell back to
|
|
46
|
+
Net::HTTP's own 60s defaults on both — a full minute an agent loop could
|
|
47
|
+
be stuck mid-checkout before anything reacted. It now defaults to a 5s
|
|
48
|
+
open / 30s read timeout (mirroring `Check#get`'s own explicit precedent),
|
|
49
|
+
overridable per call via `open_timeout:`/`read_timeout:` keywords.
|
|
50
|
+
`Support::Retry#retryable_error?` now also treats `Net::OpenTimeout` and
|
|
51
|
+
`Net::ReadTimeout` as retryable — neither carries a `status`, but both are
|
|
52
|
+
exactly the "the upstream didn't do the work, try again" case the module
|
|
53
|
+
exists for.
|
|
54
|
+
- Documentation only, no code change. Fixes the README's "Usage" snippet:
|
|
55
|
+
it called `server.start` on the `MCP::Server` `Mcp::Server.build` returns,
|
|
56
|
+
but that class (mcp gem 0.25.0) has no `#start` — only
|
|
57
|
+
`MCP::Server::Transports::StdioTransport#open` reads stdio frames. Every
|
|
58
|
+
bundled adapter's `exe/` had the same bug; this just fixes the README to
|
|
59
|
+
match.
|
|
60
|
+
|
|
7
61
|
## [0.9.0] - 2026-09-23
|
|
8
62
|
|
|
9
63
|
- New `Support::OfferRanking` and `Support::Escalation`: the offer-ranking
|
data/README.md
CHANGED
|
@@ -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.
|
|
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)
|
data/lib/portage/ucp/check.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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
|
|
@@ -79,8 +80,7 @@ module Portage
|
|
|
79
80
|
def get(uri, limit = REDIRECT_LIMIT)
|
|
80
81
|
raise Portage::Ucp::Error, "too many redirects" if limit.zero?
|
|
81
82
|
|
|
82
|
-
response =
|
|
83
|
-
open_timeout: 5, read_timeout: 5) do |http|
|
|
83
|
+
response = Support::Connection.start(uri, route: :probe, open_timeout: 5, read_timeout: 5) do |http|
|
|
84
84
|
http.get(uri.request_uri, { "User-Agent" => "portage-ucp-check" })
|
|
85
85
|
end
|
|
86
86
|
|
|
@@ -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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
data/lib/portage/ucp/errors.rb
CHANGED
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
|