Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow STS regional requests without 'Host' header #2827

Merged
merged 2 commits into from
Jul 6, 2023
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- Nothing should go in this section, please add to the latest unreleased version
(and update the corresponding date), or add a new version.

## [1.19.6] - 2023-07-05

### Fixed
- Support Authn-IAM regional requests when host value is missing from signed headers.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lists should be surrounded by blank lines

[cyberark/conjur#2827](https://github.com/cyberark/conjur/pull/2827)

## [1.19.5] - 2023-06-29

### Security
Expand Down
50 changes: 45 additions & 5 deletions app/domain/authentication/authn_iam/authenticator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,34 @@ def extract_relevant_data(response)

# Call to AWS STS endpoint using the provided authentication header
def attempt_signed_request(signed_headers)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels like with the multiple forms of region lookup, paired with fallback validation, we should move to region extraction and STS request validation. What do you think about having extract_sts_host return the region? Based on that response, we can build the STS url and validate it while falling back to the global endpoint if the regional validation fails.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With this approach would you ignore the Host header and always rely on the region from the credential string? Otherwise I don't see a benefit to adding another pattern match to extract the STS region from the Host header when the STS url is readily available if it exists

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd recommend something like:

def aws_call(region:, headers:)
  host = if region == 'global'
    'sts.amazonaws.com'
  else
    "sts.#{region}.amazonaws.com"
  end
  aws_request = URI("https://#{host}/?Action=GetCallerIdentity&Version=2011-06-15")
  begin
    @client.get_response(aws_request, headers)
  rescue => e
    # Handle any network failures with a generic verification error
    raise(Errors::Authentication::AuthnIam::VerificationError, e)
  end
end

# Call to AWS STS endpoint using the provided authentication header
def attempt_signed_request(signed_headers)
  region = extract_sts_region(signed_headers)

  # Attempt check using the discovered region 
  response = aws_call(region: region, headers: signed_headers)
  return response if response.code.to_i == 200

  # If the discovered region is `us-east-1`, also check if the request
  # was made from the 
  if region == 'us-east-1'
    response = aws_call(region: 'global', headers: signed_headers)
    return response if response.code.to_i == 200
  end

  raise(Errors::Authentication::AuthnIam::VerificationError, 'Signature is invalid')
end

def extract_sts_region(signed_headers)        
  if signed_headers['host'].present? 
    if signed_headers['host'] == 'sts.amazonaws.com'
      'global'
    elsif match = signed_headers['host'].match(%r{sts.([\w\-]+).amazonaws.com})
      match.captures.first
    end
  else
    match = signed_headers['authorization'].match(%r{Credential=[^/]+/[^/]+/([^/]+)/})
    return match.captures.first if match

    raise(Errors::Authentication::AuthnIam::InvalidAWSHeaders, 'Failed to extract AWS region from authorization header')
  end
end

This separates the following into separate methods:

  • Region extraction
  • Retry behavior on the global endpoint
  • STS lookup

Note: the above is pseudo code, so please refactor as makes sense.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great feedback, thanks! I've incorporated this but let me know if there's anything else

aws_request = URI("https://#{signed_headers['host']}/?Action=GetCallerIdentity&Version=2011-06-15")
begin
@client.get_response(aws_request, signed_headers)
region = extract_sts_region(signed_headers)

# Attempt request using the discovered region and return immediately if successful
response = aws_call(region: region, headers: signed_headers)
return response if response.code.to_i == 200

# If the discovered region is `us-east-1`, fallback to the global endpoint
if region == 'us-east-1'
@logger.debug(LogMessages::Authentication::AuthnIam::RetryWithGlobalEndpoint.new)
fallback_response = aws_call(region: 'global', headers: signed_headers)
return fallback_response if fallback_response.code.to_i == 200
end

# Handle any network failures with a generic verification error
return response
Comment on lines +59 to +70
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wonder if in the case where region == 'us-east-1' and fallback_response.code.to_i != 200 we want to return the fallback_response instead of the primary response.

Copy link
Contributor Author

@gl-johnson gl-johnson Jul 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wasn't too sure about this. There isn't a way to definitively know whether the regional endpoint or the global endpoint was used to generate the signed headers even after attempting both requests. AWS throws a fairly generic The request signature we calculated does not match the signature you provided error message either way

My thinking was that the built-in retry logic should essentially be obscured from the UX so prioritize the information in original response in this case

end

def aws_call(region:, headers:)
host = if region == 'global'
'sts.amazonaws.com'
else
"sts.#{region}.amazonaws.com"
end
aws_request = URI("https://#{host}/?Action=GetCallerIdentity&Version=2011-06-15")
begin
@client.get_response(aws_request, headers)
rescue StandardError => e
raise(Errors::Authentication::AuthnIam::VerificationError.new(e))
# Handle any network failures with a generic verification error
raise(Errors::Authentication::AuthnIam::VerificationError, e)
end
end

Expand All @@ -76,6 +97,25 @@ def response_from_signed_request(aws_headers)
body.dig('ErrorResponse', 'Error', 'Message').to_s.strip
)
end

# Extracts the STS region from the host header if it exists.
# If not, we use the authorization header's credential string, i.e.:
# Credential=AKIAIOSFODNN7EXAMPLE/20220830/us-east-1/sts/aws4_request
def extract_sts_region(signed_headers)
host = signed_headers['host']
gl-johnson marked this conversation as resolved.
Show resolved Hide resolved

if host == 'sts.amazonaws.com'
return 'global'
end

match = host&.match(%r{sts.([\w\-]+).amazonaws.com})
gl-johnson marked this conversation as resolved.
Show resolved Hide resolved
return match.captures.first if match
gl-johnson marked this conversation as resolved.
Show resolved Hide resolved
gl-johnson marked this conversation as resolved.
Show resolved Hide resolved
gl-johnson marked this conversation as resolved.
Show resolved Hide resolved

match = signed_headers['authorization']&.match(%r{Credential=[^/]+/[^/]+/([^/]+)/})
return match.captures.first if match

raise Errors::Authentication::AuthnIam::InvalidAWSHeaders, 'Failed to extract AWS region from authorization header'
end
end
end
end
5 changes: 5 additions & 0 deletions app/domain/logs.rb
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,11 @@ module AuthnIam
code: "CONJ00036D"
)

RetryWithGlobalEndpoint = ::Util::TrackableLogMessageClass.new(
msg: "Retrying IAM request signed in 'us-east-1' region with global STS endpoint.",
code: "CONJ00043D"
)

end

module AuthnAzure
Expand Down
2 changes: 1 addition & 1 deletion config/environments/development.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
# https://guides.rubyonrails.org/configuring.html#actiondispatch-hostauthorization

# Accept multiple hosts for parallel tests
config.hosts << /^conjur[0-9]*$/
config.hosts << /conjur[0-9]*/

# eager_load needed to make authentication work without the hacky
# loading code...
Expand Down
13 changes: 8 additions & 5 deletions dev/start
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,6 @@
set -ex
set -o pipefail

# CC servers can't find it for some reason. Local shellcheck is fine.
# shellcheck disable=SC1091
source "../ci/oauth/keycloak/keycloak_functions.sh"

# SCRIPT GLOBAL STATE

# Set up VERSION file for local development
Expand Down Expand Up @@ -229,7 +225,14 @@ configure_oidc_authenticators() {
}

setup_keycloak() {
# Start keycloak docker compose service

pushd "../ci"
# CC servers can't find it for some reason. Local shellcheck is fine.
# shellcheck disable=SC1091
source "oauth/keycloak/keycloak_functions.sh"
popd

# Start keycloak docker-compose service
services+=(keycloak)
docker compose up -d --no-deps "${services[@]}"

Expand Down
31 changes: 31 additions & 0 deletions spec/app/domain/authentication/authn_iam/authenticator_spec.rb

Large diffs are not rendered by default.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.