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

Use Privacy Access Tokens, CAPTCHA, and Rack to rate limit sign ins and sign ups #3519

Open
wants to merge 40 commits into
base: master
Choose a base branch
from

Conversation

mercedesb
Copy link
Contributor

@mercedesb mercedesb commented Mar 2, 2023

Resolves #3518

What was the end-user or developer problem that led to this PR?

Please see #3518

Rubygems.org wants to reduce (and prevent if possible) bot activity around signup and login. Captchas are a common way to reduce activity, but can also be annoying and repetitive. We’d like to use Privacy Pass to get the same security with a smoother user experience then Captcha, although it doesn’t completely replace Captchas.

What is your fix for the problem, implemented in this PR?

Following the product design in the linked issue, I've implemented privacy pass token challenges and redemption, hcaptcha verification, and stricter rate limiting around sign in and sign up.

To test

Privacy pass is currently supported on iOS and MacOS Ventura. I tested this with Safari in MacOS Ventura 13.2.1 (it doesn't work in any non-Apple browser yet as far as I can tell).

Deployment next steps

Once we hear back from Fastly about getting added to their beta issuer we can take the following steps

  1. Uncomment the PRIVACY_PASS_ISSUER_PUBLIC_KEY and PRIVACY_PASS_ISSUER_NAME vars from config/deploy/unicorn.yaml.erb
  2. Add the var values to config/deploy/staging/secrets.ejson and config/deploy/production/secrets.ejson

Other helpful info

Apple has the best info currently about how to set up your web server to serve privacy pass challenges and redeem privacy pass tokens.

The various docs around the IETF RFC are also helpful

hcaptcha was straightforward to get set up and working. Their docs are pretty good.

Make sure the following tasks are checked

@codecov
Copy link

codecov bot commented Mar 2, 2023

Codecov Report

Merging #3519 (2619a61) into master (ebf32fe) will decrease coverage by 0.05%.
The diff coverage is 97.81%.

@@            Coverage Diff             @@
##           master    #3519      +/-   ##
==========================================
- Coverage   98.69%   98.65%   -0.05%     
==========================================
  Files         198      204       +6     
  Lines        4902     5120     +218     
==========================================
+ Hits         4838     5051     +213     
- Misses         64       69       +5     
Impacted Files Coverage Δ
lib/hcaptcha_verifier.rb 90.00% <90.00%> (ø)
app/controllers/users_controller.rb 97.50% <96.87%> (-2.50%) ⬇️
app/controllers/concerns/captcha_verifiable.rb 100.00% <100.00%> (ø)
...p/controllers/concerns/privacy_pass_supportable.rb 100.00% <100.00%> (ø)
app/controllers/sessions_controller.rb 100.00% <100.00%> (ø)
app/helpers/hcaptcha_helper.rb 100.00% <100.00%> (ø)
lib/privacy_pass_redeemer.rb 100.00% <100.00%> (ø)
lib/privacy_pass_tokenizer.rb 100.00% <100.00%> (ø)

Help us with your feedback. Take ten seconds to tell us how you rate us. Have a feature suggestion? Share it here.

@@ -209,7 +210,11 @@
end
end

get '/sign_in' => 'clearance/sessions#new', as: 'sign_in'
resource :user, only: [] do
Copy link
Member

Choose a reason for hiding this comment

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

is resource only: [] any different from using namespace ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good question, I was following the pattern that currently exists in routes for session and I think in this case it makes more sense. /user isn't a namespace but a singular resource. Namespacing it would mean that by convention we would expect the controller to exist within a user module.

From the Rails docs

2.5 Singular Resources
Sometimes, you have a resource that clients always look up without referencing an ID. ..
2.6 Controller Namespaces and Routing
You may wish to organize groups of controllers under a namespace. Most commonly, you might group a number of administrative controllers under an Admin:: namespace, and place these controllers under the app/controllers/admin directory. You can route to such a group by using a namespace block:

app/controllers/sessions_controller.rb Outdated Show resolved Hide resolved
app/controllers/sessions_controller.rb Show resolved Hide resolved
app/controllers/sessions_controller.rb Outdated Show resolved Hide resolved
app/controllers/users_controller.rb Outdated Show resolved Hide resolved
lib/hcaptcha_verifier.rb Outdated Show resolved Hide resolved
lib/hcaptcha_verifier.rb Outdated Show resolved Hide resolved
# uint8_t token_key_id[32];
# uint8_t authenticator[Nk];
# } Token;
TOKEN_TYPE_LENGTH = 1
Copy link
Member

Choose a reason for hiding this comment

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

should this be 2 since it's a uint16_t as opposed to a uint8?

Copy link
Contributor Author

@mercedesb mercedesb Mar 3, 2023

Choose a reason for hiding this comment

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

No, because we're checking the length of unpacked data and we're unpacking this via the uint16 directive (n)

lib/privacy_pass_redeemer.rb Outdated Show resolved Hide resolved
lib/privacy_pass_tokenizer.rb Outdated Show resolved Hide resolved
@mercedesb mercedesb requested a review from segiddins March 3, 2023 14:46
@mercedesb mercedesb changed the title Use Privacy Pass, captcha, and Rack to rate limit sign ins and sign ups Use Privacy Access Tokens, CAPTCHA, and Rack to rate limit sign ins and sign ups Mar 3, 2023
@mercedesb mercedesb force-pushed the mb-rate-limit-sign-ups branch 2 times, most recently from eca0839 to 3f6cce8 Compare March 3, 2023 22:10
@indirect
Copy link
Member

indirect commented Mar 7, 2023

Fastly says we are in their beta access group now, and they are targeting end of Q2 for the beta issuer. In the meantime, I would be okay to ship this with just captchas now, and add privacy pass support once there's a production issuer. (Fastly says the demo issuer that we built this to work with is not for production use, so we shouldn't ship anything that depends on that.)

@simi simi self-requested a review March 7, 2023 01:14
@mercedesb
Copy link
Contributor Author

In the meantime, I would be okay to ship this with just captchas now, and add privacy pass support once there's a production issuer.

Based on @indirect's most recent feedback, I went ahead and feature flagged the privacy pass implementation using LaunchDarkly (someone with access to that will need to configure the variation)


def privacy_pass_enabled?
ld_context = LaunchDarkly::LDContext.with_key(self.class.name)
Rails.configuration.launch_darkly_client.variation("privacy_pass.enabled", ld_context, false)
Copy link
Member

Choose a reason for hiding this comment

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

nit: let's call the flag "gemcutter.privacy_pass.enabled". should we use the current controller & action as the key, so we can test by only turning it on for a single action, rather than every action?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I updated the name.

I think using the current controller would be good. My only concern about using the action is that for every place we want it (sign up or sign in) we actually need it enabled in 2 actions. The new action presents the challenge and the create action handles redeeming the token.

Copy link
Member

Choose a reason for hiding this comment

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

fair enough! I just think we'll need a kind on the LDContext of say controller so it doesn't look like a user

<div class="mfa__container">
<div class="mfa__option">
<%= form_tag captcha_create_session_path, method: :post, class: "mfa-form" do %>
<div class="h-captcha" data-sitekey="<%= ENV.fetch("HCAPTCHA_SITE_KEY", "10000000-ffff-ffff-ffff-000000000001") %>"></div>
Copy link
Member

Choose a reason for hiding this comment

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

Can you move HCAPTCHA_SITE_KEY to be ingested in config/secrets.yml from ENV (and specify this default for dev/testing environment if useful), introduce helper like hcaptcha_site_key to read secrets and use it in templates?

sitekey: ENV.fetch("HCAPTCHA_SITE_KEY", "10000000-ffff-ffff-ffff-000000000001")
}

response = RestClient.post VERIFY_ENDPOINT,
Copy link
Member

Choose a reason for hiding this comment

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

RestClient was replaced by Faraday recently at #3602

@@ -172,6 +194,16 @@ def setup_mfa_authentication
session[:mfa_user] = @user.id
end

def setup_captcha_verification
create_catpcha_user(id: @user.id)
Copy link
Member

Choose a reason for hiding this comment

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

Per my local testing, captcha is also created when trying to login with unknown email, that means @user is nil in here and it crashes.

Copy link
Member

Choose a reason for hiding this comment

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

The same seems applied when using wrong password for existing account. find_user returns nil -> @user is nil.

@simi
Copy link
Member

simi commented Mar 17, 2023

I did some testing and left some initial comments. Regarding ENV values, please move them into config/secrets.yml (could be ingested from ENV still) or to config/rubygems.yml for fixed values.

Here's a screenshot from testing of captcha. The red text is just notice of using testing environment. It looks good to me.

image

@mercedesb
Copy link
Contributor Author

  • put hcpatcha vals into secrets with testing creds as dev defaults
  • replaced RestClient with Faraday
  • fixed bug when user not found on login but captcha conditions met

@mercedesb
Copy link
Contributor Author

I got an email from GitGuardian about an exposed generic password. But I think that's ok b/c Hcaptcha publicly publishes this site key and secret for development testing

# fastly demo fallback
# https://www.fastly.com/blog/private-access-tokens-stepping-into-the-privacy-respecting-captcha-less#demo-configuration
# rubocop:disable Layout/LineLength
ENV.fetch("PRIVACY_PASS_ISSUER_PUBLIC_KEY",
Copy link
Member

Choose a reason for hiding this comment

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

Would it make sense to move this also to rubygems.yml/secrets.yml?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can. It's not really a secret but no harm in adding it there.


def valid_privacy_pass_redemption?
return false unless privacy_pass_enabled?
return session[:redeemed_privacy_pass] unless session[:redeemed_privacy_pass].nil?
Copy link
Member

Choose a reason for hiding this comment

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

I found out this a little hard to read during testing. Would it be possible to keep the condition "positive"?

return session[:redeemed_privacy_pass] if session.key?(:redeemed_privacy_pass)

@simi
Copy link
Member

simi commented Mar 19, 2023

@mercedesb any more hints for local Privacy pass testing? I tried with Privacy pass extension, but I couldn't get it to work 🤔.

@mercedesb
Copy link
Contributor Author

any more hints for local Privacy pass testing? I tried with Privacy pass extension, but I couldn't get it to work

  1. We're using the Fastly issuer and Fastly does not work with the Privacy Pass extension. That extension is a Cloudflare product.
  2. I never had much luck testing with the extension anywhere on the web and neither did a colleague of mine. They have a lot of open issues in their client repo with people having similar trouble and I don't have a lot of confidence in how well-supported the extension is.

Privacy Pass is currently supported natively only in iOS 16+, iPad 16+, macOS Ventura (13+). Through my testing, I had to use the Safari browser. But if you're running a supported OS with Safari, that should be all you need for testing.

If things are working as expected, you won't see the 401 response with the token challenge in the browser's dev tools' Network tab. You'll see a 200 with an Authorization header from the client which means the challenge was handled successfully. But you can step through the code to see the entire flow.

@simi
Copy link
Member

simi commented Mar 19, 2023

That extension supports also hCaptcha and seems active on hCaptcha rubygems.org page, but I couldn't get it to work as well. I don't have any Apple product around to test this.

@mercedesb
Copy link
Contributor Author

That extension supports also hCaptcha and seems active on hCaptcha rubygems.org page

hcaptcha is a different issuer and won't work with Fastly tokens. We are using the Fastly issuer.

I don't have any Apple product around to test this.

That's a bummer. Given that Apple seems to be the only native support for PATs at the moment, I do think it important that it's tested there.

elsif @user.save
handle_user_after_save
else
render template: "users/new"
Copy link
Member

Choose a reason for hiding this comment

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

If I understand it well, this is tricky to test. valid? is called few lines before, but this save can still fail. In theory codecov could be fixed by using save! and rescue_from, but I'm not sure if that's worth it. 🤔

@simi
Copy link
Member

simi commented Mar 19, 2023

That's a bummer. Given that Apple seems to be the only native support for PATs at the moment, I do think it important that it's tested there.

We can ask someone for help with testing on staging.

Other than that part I can't test, this looks good to me. 💪

should_verify(keys, SIGN_UP_CAPTCHA_THRESHOLD)
end

def call(client_response, remote_ip)
Copy link
Member

Choose a reason for hiding this comment

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

nitpick: I'm personally not big fan of call methods if not used as a proc. Would it make sense to rename this to something like verified?. Similar change could be done in PrivacyPassRedeemer.

f.request :url_encoded
f.response :json
f.response :logger, logger, headers: false, errors: true
f.response :raise_error
Copy link
Member

Choose a reason for hiding this comment

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

Is this codecov complain related to stubs being used in tests instead of using test adapter? 🤔

https://lostisland.github.io/faraday/adapters/testing

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

Successfully merging this pull request may close these issues.

Add privacy pass/trusttoken hidden captcha support to rate limit signups
4 participants