Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- DPoP `htu` validation against Authorization Servers on non-default ports. The
SSRF-safe pinned HTTP client now keeps a non-default port in the `Host` header,
and DPoP proof generation normalizes the `htu` claim to match: an explicit
default port (`:80`/`:443`) is dropped, non-default ports are preserved, IPv6
literals stay bracketed, and any userinfo is stripped from `htu`
(RFC 9110 §7.2, RFC 9449 §4.3).

## [0.1.0] - 2026-05-17

- Initial release.
24 changes: 21 additions & 3 deletions core/authplane/dpop.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"encoding/json"
"fmt"
"net/url"
"strings"
"time"

"github.com/go-jose/go-jose/v4"
Expand Down Expand Up @@ -140,7 +141,7 @@ func NewDPoPSignerWithKey(key stdcrypto.Signer, alg jose.SignatureAlgorithm) (*D
// GenerateProof generates a DPoP proof JWT for the given HTTP method and URL.
// Per RFC 9449 §4.3, the htu claim is set to the target URI excluding query and fragment.
func (d *DPoPSigner) GenerateProof(method, rawURL string, opts *DPoPProofOptions) (string, error) {
htu := stripQueryFragment(rawURL)
htu := normalizeHTU(rawURL)
now := time.Now()
claims := map[string]any{
"jti": generateJTI(),
Expand Down Expand Up @@ -178,15 +179,32 @@ func (d *DPoPSigner) Thumbprint() string {
return d.jkt
}

// stripQueryFragment removes query and fragment from a URL per RFC 9449 §4.3.
func stripQueryFragment(rawURL string) string {
// normalizeHTU derives the DPoP htu claim from a target URL. Per RFC 9449 §4.3
// the htu is the target URI without query and fragment; the target URI carries
// no userinfo (RFC 9110 §7.1), so credentials are stripped rather than leaked
// into the signed proof. The authority is also normalized to mirror the outbound
// Host header (RFC 9110 §7.2): an explicit default port (:80 for http, :443 for
// https) is dropped while non-default ports are kept. Without this, an htu
// carrying an explicit default port would not match the host the AS reconstructs
// from a port-less Host header. IPv6 literals stay bracketed.
func normalizeHTU(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return rawURL
}
u.User = nil
u.RawQuery = ""
u.Fragment = ""
u.RawFragment = ""
if port := u.Port(); port != "" {
if (u.Scheme == "http" && port == "80") || (u.Scheme == "https" && port == "443") {
host := u.Hostname()
if strings.Contains(host, ":") {
host = "[" + host + "]"
}
u.Host = host
}
}
return u.String()
}

Expand Down
72 changes: 72 additions & 0 deletions core/authplane/dpop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,78 @@ func TestGenerateProof_HTUStripsQueryAndFragment(t *testing.T) {
}
}

// htu must mirror the outbound Host header's default-port rule (RFC 9110 §7.2):
// an explicit :80/:443 is dropped, non-default ports are kept, and IPv6 literals
// stay bracketed. Otherwise an AS reconstructing the request URI from a port-less
// Host header would see a host mismatch against an htu carrying an explicit
// default port. See core/internal/ssrf hostHeader and verifier/dpop validateHTU.
func TestGenerateProof_HTUNormalizesDefaultPort(t *testing.T) {
signer, _ := NewDPoPSigner(jose.ES256)

tests := []struct {
name string
url string
wantHTU string
}{
{"explicit https default port stripped", "https://auth.example.com:443/token", "https://auth.example.com/token"},
{"explicit http default port stripped", "http://auth.example.com:80/token", "http://auth.example.com/token"},
{"https non-default port preserved", "https://auth.example.com:9000/token", "https://auth.example.com:9000/token"},
{"http non-default port preserved", "http://localhost:9000/token", "http://localhost:9000/token"},
{"ipv6 default port stripped, brackets kept", "https://[::1]:443/token", "https://[::1]/token"},
{"ipv6 non-default port preserved", "http://[::1]:9000/token", "http://[::1]:9000/token"},
{"no port unchanged", "https://auth.example.com/token", "https://auth.example.com/token"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
proof, err := signer.GenerateProof("POST", tt.url, nil)
if err != nil {
t.Fatalf("GenerateProof: %v", err)
}
claims, err := testutil.ParseSignedToken(proof)
if err != nil {
t.Fatalf("parse: %v", err)
}
if claims["htu"] != tt.wantHTU {
t.Errorf("htu = %v, want %v", claims["htu"], tt.wantHTU)
}
})
}
}

// Per RFC 9449 §4.3 the htu is the target URI, which carries no userinfo
// (RFC 9110 §7.1). Credentials must never leak into the signed proof, and an AS
// reconstructs the request URI without userinfo, so it must be stripped.
func TestGenerateProof_HTUStripsUserinfo(t *testing.T) {
signer, _ := NewDPoPSigner(jose.ES256)

tests := []struct {
name string
url string
wantHTU string
}{
{"user and password stripped", "https://user:pass@auth.example.com/token", "https://auth.example.com/token"},
{"user only stripped", "https://user@auth.example.com/token", "https://auth.example.com/token"},
{"userinfo with non-default port", "https://user:pass@auth.example.com:9000/token", "https://auth.example.com:9000/token"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
proof, err := signer.GenerateProof("POST", tt.url, nil)
if err != nil {
t.Fatalf("GenerateProof: %v", err)
}
claims, err := testutil.ParseSignedToken(proof)
if err != nil {
t.Fatalf("parse: %v", err)
}
if claims["htu"] != tt.wantHTU {
t.Errorf("htu = %v, want %v", claims["htu"], tt.wantHTU)
}
})
}
}

func TestNewDPoPSignerWithKey_ES256(t *testing.T) {
key, err := testutil.GenerateES256Key()
if err != nil {
Expand Down
26 changes: 24 additions & 2 deletions core/internal/ssrf/ssrf.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ func pinnedGet(ctx context.Context, v *ValidatedURL, settings FetchSettings, _ *
if err != nil {
return nil, fmt.Errorf("%w: create request: %v", ErrSSRFBlocked, err)
}
req.Host = v.Host
req.Host = hostHeader(*v)
pinnedClient := pinnedHTTPClient(settings, v.Host)
resp, err := pinnedClient.Do(req)
if err != nil {
Expand All @@ -315,7 +315,7 @@ func pinnedPost(ctx context.Context, v *ValidatedURL, settings FetchSettings, _
if err != nil {
return nil, fmt.Errorf("%w: create request: %v", ErrSSRFBlocked, err)
}
req.Host = v.Host
req.Host = hostHeader(*v)
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
Expand Down Expand Up @@ -440,6 +440,28 @@ func readLimitedResponse(resp *http.Response, maxSize int64) (*HTTPResponse, err
}, nil
}

// hostHeader returns the value for the outbound HTTP Host header.
//
// Per RFC 9110 §7.2 the Host header must mirror the URI authority, including
// the port when it is not the scheme's default. This is required for RFC 9449
// DPoP htu validation: the Authorization Server reconstructs the request URI
// from the Host header and compares it against the proof's htu, so dropping a
// non-default port (e.g. :9000) breaks the match. IPv6 literals are bracketed
// per RFC 3986 §3.2.2.
func hostHeader(v ValidatedURL) string {
defaultPort := 80
if v.Scheme == "https" {
defaultPort = 443
}
if v.Port == defaultPort {
if strings.Contains(v.Host, ":") {
return "[" + v.Host + "]"
}
return v.Host
}
return net.JoinHostPort(v.Host, strconv.Itoa(v.Port))
}

func formatIPForURL(ip net.IP) string {
if ip.To4() != nil {
return ip.String()
Expand Down
Loading
Loading