Skip to content

Commit 83ceb79

Browse files
author
aashh
committed
fix: require Destination on all SAML responses
Previously, unsigned responses with an empty Destination were accepted without any Destination validation. This allowed an attacker to replay a captured response to a different SP, since the Destination was the only non-cryptographic binding to the intended recipient. Now Destination is always required and validated, regardless of whether the response is signed. The SAML spec says Destination MUST be present on signed responses and SHOULD be present otherwise — we upgrade the SHOULD to a MUST for defense in depth. Fixes #12
1 parent 3465403 commit 83ceb79

File tree

2 files changed

+49
-15
lines changed

2 files changed

+49
-15
lines changed

service_provider.go

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -986,12 +986,8 @@ const (
986986
// and properties are met.
987987
func (sp *ServiceProvider) parseResponse(responseEl *etree.Element, possibleRequestIDs []string, now time.Time, signatureRequirement signatureRequirement, currentURL url.URL) (*Assertion, error) {
988988
var responseSignatureErr error
989-
var responseHasSignature bool
990989
if signatureRequirement == signatureRequired {
991990
responseSignatureErr = sp.validateSignature(responseEl)
992-
if responseSignatureErr != errSignatureElementNotPresent {
993-
responseHasSignature = true
994-
}
995991

996992
// Note: we're deferring taking action on the signature validation until after we've
997993
// processed the request attributes, because certain test cases seem to require this mis-feature.
@@ -1005,14 +1001,18 @@ func (sp *ServiceProvider) parseResponse(responseEl *etree.Element, possibleRequ
10051001
return nil, fmt.Errorf("cannot unmarshal response: %v", err)
10061002
}
10071003

1008-
// If the response is *not* signed, the Destination may be omitted.
1009-
if responseHasSignature || response.Destination != "" {
1010-
// Per section 3.4.5.2 of the SAML spec, Destination must match the location at which the response was received, i.e. currentURL.
1011-
// Historically, we checked against the SP's ACS URL instead of currentURL, which is usually the same but may differ in query params.
1012-
// To mitigate the risk of switching to comparing against currentURL, we still allow it if the ACS URL matches, even if the current URL doesn't.
1013-
if response.Destination != currentURL.String() && response.Destination != sp.AcsURL.String() {
1014-
return nil, fmt.Errorf("`Destination` does not match requested URL or AcsURL (destination %q, requested %q, acs %q)", response.Destination, currentURL.String(), sp.AcsURL.String())
1015-
}
1004+
// Per section 3.4.5.2 of the SAML spec, Destination MUST be present
1005+
// when a signature is present, and SHOULD be present otherwise. We
1006+
// require it always: for unsigned responses the Destination is the
1007+
// only non-cryptographic binding to this SP, and omitting it would
1008+
// let an attacker replay a response across service providers.
1009+
if response.Destination == "" {
1010+
return nil, fmt.Errorf("`Destination` is required but missing")
1011+
}
1012+
// Historically, we checked against the SP's ACS URL instead of currentURL, which is usually the same but may differ in query params.
1013+
// To mitigate the risk of switching to comparing against currentURL, we still allow it if the ACS URL matches, even if the current URL doesn't.
1014+
if response.Destination != currentURL.String() && response.Destination != sp.AcsURL.String() {
1015+
return nil, fmt.Errorf("`Destination` does not match requested URL or AcsURL (destination %q, requested %q, acs %q)", response.Destination, currentURL.String(), sp.AcsURL.String())
10161016
}
10171017

10181018
if err := sp.validateRequestID(response, possibleRequestIDs); err != nil {

service_provider_test.go

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -946,7 +946,7 @@ func (test *ServiceProviderTest) replaceDestination(newDestination string) {
946946
[]byte(`Destination="https://15661444.ngrok.io/saml2/acs"`), []byte(newStr), 1)
947947
}
948948

949-
func TestSPCanProcessResponseWithoutDestination(t *testing.T) {
949+
func TestSPRejectsResponseWithoutDestination(t *testing.T) {
950950
test := NewServiceProviderTest(t)
951951
s := ServiceProvider{
952952
Key: test.Key,
@@ -962,7 +962,41 @@ func TestSPCanProcessResponseWithoutDestination(t *testing.T) {
962962
test.replaceDestination("")
963963
req.PostForm.Set("SAMLResponse", base64.StdEncoding.EncodeToString(test.SamlResponse))
964964
_, err = s.ParseResponse(&req, []string{"id-9e61753d64e928af5a7a341a97f420c9"})
965+
assert.Check(t, is.Error(err.(*InvalidResponseError).PrivateErr,
966+
"`Destination` is required but missing"))
967+
}
968+
969+
func TestSPRejectsUnsignedResponseWithoutDestination(t *testing.T) {
970+
// Regression test for issue #12: On main, unsigned responses without
971+
// Destination were silently accepted. The fix requires Destination always,
972+
// because for unsigned responses it is the only binding to this SP.
973+
test := NewServiceProviderTest(t)
974+
s := ServiceProvider{
975+
Key: test.Key,
976+
Certificate: test.Certificate,
977+
MetadataURL: mustParseURL("https://15661444.ngrok.io/saml2/metadata"),
978+
AcsURL: mustParseURL("https://15661444.ngrok.io/saml2/acs"),
979+
IDPMetadata: &EntityDescriptor{},
980+
}
981+
err := xml.Unmarshal(test.IDPMetadata, &s.IDPMetadata)
965982
assert.Check(t, err)
983+
984+
// Build a minimal unsigned SAML response with no Destination attribute.
985+
// This simulates an IdP sending a response without any audience binding.
986+
unsignedResp := `<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol"` +
987+
` ID="_test123" InResponseTo="id-9e61753d64e928af5a7a341a97f420c9"` +
988+
` IssueInstant="2015-12-01T01:56:21.375Z" Version="2.0">` +
989+
`<saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"` +
990+
` Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">` +
991+
`https://idp.testshib.org/idp/shibboleth</saml2:Issuer>` +
992+
`<saml2p:Status><saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></saml2p:Status>` +
993+
`</saml2p:Response>`
994+
995+
req := http.Request{PostForm: url.Values{}, URL: &s.AcsURL}
996+
req.PostForm.Set("SAMLResponse", base64.StdEncoding.EncodeToString([]byte(unsignedResp)))
997+
_, err = s.ParseResponse(&req, []string{"id-9e61753d64e928af5a7a341a97f420c9"})
998+
assert.Check(t, is.Error(err.(*InvalidResponseError).PrivateErr,
999+
"`Destination` is required but missing"))
9661000
}
9671001

9681002
func (test *ServiceProviderTest) responseDom(t *testing.T) (doc *etree.Document) {
@@ -1079,7 +1113,7 @@ func TestServiceProviderMissingDestinationWithSignaturePresent(t *testing.T) {
10791113
req.PostForm.Set("SAMLResponse", base64.StdEncoding.EncodeToString(bytes))
10801114
_, err = s.ParseResponse(&req, []string{"id-9e61753d64e928af5a7a341a97f420c9"})
10811115
assert.Check(t, is.Error(err.(*InvalidResponseError).PrivateErr,
1082-
"`Destination` does not match requested URL or AcsURL (destination \"\", requested \"https://15661444.ngrok.io/saml2/acs\", acs \"https://15661444.ngrok.io/saml2/acs\")"))
1116+
"`Destination` is required but missing"))
10831117
}
10841118

10851119
func TestSPMismatchedDestinationsWithSignaturePresent(t *testing.T) {
@@ -1142,7 +1176,7 @@ func TestSPMissingDestinationWithSignaturePresent(t *testing.T) {
11421176
req.PostForm.Set("SAMLResponse", base64.StdEncoding.EncodeToString(bytes))
11431177
_, err = s.ParseResponse(&req, []string{"id-9e61753d64e928af5a7a341a97f420c9"})
11441178
assert.Check(t, is.Error(err.(*InvalidResponseError).PrivateErr,
1145-
"`Destination` does not match requested URL or AcsURL (destination \"\", requested \"https://15661444.ngrok.io/saml2/acs\", acs \"https://15661444.ngrok.io/saml2/acs\")"))
1179+
"`Destination` is required but missing"))
11461180
}
11471181

11481182
func TestSPInvalidAssertions(t *testing.T) {

0 commit comments

Comments
 (0)