Skip to content

Commit 3e2328f

Browse files
committed
Add to the README how to force SP-Initiate flow and Prevent Reply Attacks
1 parent a2e1fcf commit 3e2328f

File tree

1 file changed

+107
-2
lines changed

1 file changed

+107
-2
lines changed

README.md

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -687,7 +687,7 @@ advanced usage scenarios:
687687
- Specifying separate SP certificates for signing and encryption.
688688

689689
The `sp_cert_multi` parameter replaces `certificate` and `private_key`
690-
(you may not specify both pparameters at the same time.) `sp_cert_multi` has the following shape:
690+
(you may not specify both parameters at the same time.) `sp_cert_multi` has the following shape:
691691

692692
```ruby
693693
settings.sp_cert_multi = {
@@ -910,7 +910,7 @@ end
910910

911911
### Attribute Service
912912

913-
To request attributes from the IdP the SP needs to provide an attribute service within it's metadata and reference the index in the assertion.
913+
To request attributes from the IdP the SP needs to provide an attribute service within its metadata and reference the index in the assertion.
914914

915915
```ruby
916916
settings = RubySaml::Settings.new
@@ -964,6 +964,111 @@ end
964964
MyMetadata.new.generate(settings)
965965
```
966966

967+
### Preventing Replay Attacks
968+
969+
A replay attack is when an attacker intercepts a valid SAML assertion and "replays" it at a later time to gain unauthorized access.
970+
971+
The library only checks the assertion's validity window (`NotBefore` and `NotOnOrAfter` conditions). An attacker can replay a valid assertion as many times as they want within this window.
972+
973+
A robust defense requires tracking of assertion IDs to ensure any given assertion is only accepted once.
974+
975+
#### 1. Extract the Assertion ID after Validation
976+
977+
After a response has been successfully validated, get the assertion ID. The library makes this available via `response.assertion_id`.
978+
979+
980+
#### 2. Store the ID with an Expiry
981+
982+
You must store this ID in a persistent cache (like Redis or Memcached) that is shared across your servers. Do not store it in the user's session, as that is not a secure cache.
983+
984+
The ID should be stored until the assertion's validity window has passed. You will need to check how long the trusted IdPs consider the assertion valid and then add the allowed_clock_drift.
985+
986+
You can define a global value, or set this value dinamically based on the `not_on_or_after` value of the re + `allowed_clock_drift`.
987+
988+
```ruby
989+
# In your `consume` action, after a successful validation:
990+
if response.is_valid?
991+
# Prevent replay of this specific assertion
992+
assertion_id = response.assertion_id
993+
authorize_failure("Assertion ID is mandatory") if assertion_id.nil?
994+
995+
assertion_not_on_or_after = response.not_on_or_after
996+
# We set a default of 5 min expiration in case is not provided
997+
assertion_expiry = (Time.now.utc + 300) if assertion_not_on_or_after.nil?
998+
999+
# `is_new_assertion?` is your application's method to check and set the ID
1000+
# in a shared, persistent cache (e.g., Redis, Memcached).
1001+
if is_new_assertion?(assertion_id, expires_at: assertion_expiry)
1002+
# This is a new assertion, so we can proceed
1003+
session[:userid] = response.nameid
1004+
session[:attributes] = response.attributes
1005+
# ...
1006+
else
1007+
# This assertion ID has been seen before. This is a REPLAY ATTACK.
1008+
# Log the security event and reject the user.
1009+
authorize_failure("Replay attack detected")
1010+
end
1011+
else
1012+
authorize_failure("Invalid response")
1013+
end
1014+
```
1015+
1016+
Your `is_new_assertion?` method would look something like this (example for Redis):
1017+
1018+
```ruby
1019+
1020+
def is_new_assertion?(assertion_id, expires_at)
1021+
ttl = (expires_at - Time.now.utc).to_i
1022+
return false if ttl <= 0 # The assertion has already expired
1023+
1024+
# The 'nx' option tells Redis to only set the key if it does not already exist.
1025+
# The command returns `true` if the key was set, `false` otherwise.
1026+
$redis.set("saml_assertion_ids:#{assertion_id}", "1", ex: ttl, nx: true)
1027+
end
1028+
```
1029+
1030+
### Enforce SP-Initiated Flow with `InResponseTo` validation
1031+
1032+
This is the best way to prevent IdP-initiated logins and ensure that you only accept assertions that you recently requested.
1033+
1034+
#### 1. Store the `AuthnRequest` ID
1035+
1036+
When you create an `AuthnRequest`, the library assigns it a unique ID. You must store this ID, for example in the user's session *before* redirecting them to the IdP.
1037+
1038+
```ruby
1039+
def init
1040+
request = OneLogin::RubySaml::Authrequest.new
1041+
# The unique ID of the request is in request.uuid
1042+
session[:saml_request_id] = request.uuid
1043+
redirect_to(request.create(saml_settings))
1044+
end
1045+
```
1046+
1047+
#### 2. Validate the `InResponseTo` value of the `Response` with the Stored ID
1048+
1049+
When you process the `SAMLResponse`, retrieve the ID from the session and pass it to the `Response` constructor. Use `session.delete` to ensure the ID can only be used once.
1050+
1051+
```ruby
1052+
def consume
1053+
request_id = session.delete(:saml_request_id) # Use delete to prevent re-use
1054+
1055+
# You can reject the response if no previous saml_request_id was stored
1056+
raise "IdP-initiaited detected" if request_id.nil?
1057+
1058+
response = OneLogin::RubySaml::Response.new(
1059+
params[:SAMLResponse],
1060+
settings: saml_settings,
1061+
matches_request_id: request_id
1062+
)
1063+
1064+
if response.is_valid?
1065+
# ... authorize user
1066+
else
1067+
# Response is invalid, errors in response.errors
1068+
end
1069+
end
1070+
```
1071+
9671072
## Contributing
9681073

9691074
### Pay it Forward: Support RubySAML and Strengthen Open-Source Security

0 commit comments

Comments
 (0)