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

Conversation

gl-johnson
Copy link
Contributor

@gl-johnson gl-johnson commented Jun 13, 2023

Desired Outcome

Currently we assume that the signed request will feature a 'Host' header telling Conjur which STS endpoint to use.

This assumption is not valid for all HTTP clients (unless explicitly added to the request), including the Golang AWS SDK. Instead we should the following prioritized order in determining which endpoint to use:

  1. Host header if it exists
  2. AWS region (pulled from Authorization header)
  3. Global endpoint (only if result from 2 is us-east-1 and the regional endpoint failed)

Implemented Changes

  • Implemented above logic for determining STS endpoint + tests
  • Minor fixes to dev environment

Connected Issue/Story

CNJR-1904

Definition of Done

At least 1 todo must be completed in the sections below for the PR to be
merged.

Changelog

  • The CHANGELOG has been updated, or
  • This PR does not include user-facing changes and doesn't require a
    CHANGELOG update

Test coverage

  • This PR includes new unit and integration tests to go with the code
    changes, or
  • The changes in this PR do not require tests

Documentation

  • Docs (e.g. READMEs) were updated in this PR
  • A follow-up issue to update official docs has been filed here: CNJR-1907
  • This PR does not require updating any documentation

Behavior

  • This PR changes product behavior and has been reviewed by a PO, or
  • These changes are part of a larger initiative that will be reviewed later, or
  • No behavior was changed with this PR

Security

  • Security architect has reviewed the changes in this PR,
  • These changes are part of a larger initiative with a separate security review, or
  • There are no security aspects to these changes

app/domain/authentication/authn_iam/authenticator.rb Outdated Show resolved Hide resolved
app/domain/authentication/authn_iam/authenticator.rb Outdated Show resolved Hide resolved
app/domain/authentication/authn_iam/authenticator.rb Outdated Show resolved Hide resolved
CHANGELOG.md Outdated Show resolved Hide resolved
@gl-johnson gl-johnson marked this pull request as ready for review June 14, 2023 00:35
@gl-johnson gl-johnson requested a review from a team as a code owner June 14, 2023 00:35
@gl-johnson gl-johnson requested a review from a team June 14, 2023 00:35
@gl-johnson gl-johnson force-pushed the sts-host-fix branch 3 times, most recently from a22b71d to 05a48df Compare June 14, 2023 16:10
@@ -54,7 +54,27 @@ def extract_relevant_data(response)

# Call to AWS STS endpoint using the provided authentication header
def attempt_signed_request(signed_headers)
aws_request = URI("https://#{signed_headers['host']}/?Action=GetCallerIdentity&Version=2011-06-15")
sts_host = signed_headers['host'] || extract_sts_host(signed_headers)
Copy link
Contributor

@jvanderhoof jvanderhoof Jun 20, 2023

Choose a reason for hiding this comment

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

I'd recommend we move the signed header resolution into the extract_sts_host method. This allows us to keep all logic related to the endpoint lookup in one place.

sts_host = signed_headers['host'] || extract_sts_host(signed_headers)

Then we push the logic for checking header value or attempting to extract the region from the credential:

# Extract AWS region from the authorization header's credential string, i.e.:
# Credential=AKIAIOSFODNN7EXAMPLE/20220830/us-east-1/sts/aws4_request
def extract_sts_host(signed_headers)
  return signed_headers['host'] if signed_headers['host'].present?

  region = signed_headers['authorization'].match(%r{Credential=[^/]+/[^/]+/([^/]+)/})&.captures&.first
  raise(Errors::Authentication::AuthnIam::InvalidAWSHeaders, 'Failed to extract AWS region from authorization header') unless region

  "sts.#{region}.amazonaws.com"
end

@@ -54,7 +54,27 @@ 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

@gl-johnson gl-johnson force-pushed the sts-host-fix branch 2 times, most recently from 3934bd4 to d7af3d1 Compare June 28, 2023 18:13
## [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

end

match = host&.match(%r{sts.([\w\-]+).amazonaws.com})
return match&.captures&.first if match
Copy link
Contributor

Choose a reason for hiding this comment

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

I think if match makes match& irrelevant - you're right to include it because we don't want to return anything if match == nil, but if match should prevent this case.

Copy link
Contributor

Choose a reason for hiding this comment

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

This applies to line 115 as well

Copy link
Contributor

Choose a reason for hiding this comment

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

Looking at this again, and even given my suggestion:

return match.captures&.first if match

I wonder if we still need this Safe Navigation Operator.

That means:

  • if match catches cases with no matches
  • Otherwise, there is guaranteed to be at least one match - match.captures will not be nil.

We might be able to do this instead, with no change in behavior and less &s:

return match.captures.first if match

Comment on lines +59 to +70
# 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
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

@codeclimate
Copy link

codeclimate bot commented Jul 6, 2023

Code Climate has analyzed commit da7dff4 and detected 8 issues on this pull request.

Here's the issue category breakdown:

Category Count
Duplication 2
Style 2
Complexity 4

The test coverage on the diff in this pull request is 100.0% (50% is the threshold).

This pull request will bring the total coverage in the repository to 87.1% (-1.2% change).

View more on Code Climate.

Copy link
Contributor

@john-odonnell john-odonnell left a comment

Choose a reason for hiding this comment

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

LGTM!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

3 participants