Rate limit device code link attempts (#5972)

Add a dedicated `device_code_link` rate limiter for verification of the user code from the device code grant. This applies _per source IP_ rather than globally.

This is to help mitigate the User Code Brute Forcing attack as described by [RFC 8628 section 5.1](https://datatracker.ietf.org/doc/html/rfc8628#section-5.1).
This commit is contained in:
Olivier 'reivilibre authored and GitHub committed 2026-09-15 11:21:36 +01:00
commit 6dde67a547
7 files changed
+285 -5

No files matched your search

@@ -31,6 +31,13 @@ pub struct RateLimitingConfig {
/// Email authentication-specific rate limits
#[serde(default)]
pub email_authentication: EmailauthenticationRateLimitingConfig,
/// Controls how many user code verification attempts are permitted
/// based on source IP address, when linking a device through the
/// Device Authorization Grant.
/// This can protect against brute-forcing the user code.
#[serde(default = "default_device_code_link")]
pub device_code_link: RateLimiterConfiguration,
}
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
@@ -174,6 +181,10 @@ impl ConfigurationSection for RateLimitingConfig {
return Err(error_on_nested_field(error, "login", "per_account").into());
}
if let Some(error) = error_on_limiter(&self.device_code_link) {
return Err(error_on_field(error, "device_code_link").into());
}
Ok(())
}
}
@@ -257,6 +268,13 @@ fn default_email_authentication_attempt_per_session() -> RateLimiterConfiguratio
}
}
fn default_device_code_link() -> RateLimiterConfiguration {
RateLimiterConfiguration {
burst: NonZeroU32::new(10).unwrap(),
per_second: 1.0 / 60.0,
}
}
impl Default for RateLimitingConfig {
fn default() -> Self {
RateLimitingConfig {
@@ -264,6 +282,7 @@ impl Default for RateLimitingConfig {
registration: default_registration(),
account_recovery: AccountRecoveryRateLimitingConfig::default(),
email_authentication: EmailauthenticationRateLimitingConfig::default(),
device_code_link: default_device_code_link(),
}
}
}
+191 -2
View File
@@ -5,6 +5,8 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use std::sync::LazyLock;
use axum::{
Form,
extract::State,
@@ -21,11 +23,22 @@ use mas_i18n::DataLocale;
use mas_router::UrlBuilder;
use mas_storage::BoxRepository;
use mas_templates::{
DeviceLinkContext, DeviceLinkFormField, FieldError, FormState, TemplateContext, Templates,
DeviceLinkContext, DeviceLinkFormField, FieldError, FormError, FormState, TemplateContext,
Templates,
};
use opentelemetry::{Key, KeyValue, metrics::Counter};
use serde::{Deserialize, Serialize};
use crate::{PreferredLanguage, SiteConfig};
use crate::{Limiter, METER, PreferredLanguage, RequesterFingerprint, SiteConfig};
static USER_CODE_ATTEMPT_COUNTER: LazyLock<Counter<u64>> = LazyLock::new(|| {
METER
.u64_counter("mas.oauth2.device_code_link_attempt")
.with_description("Number of user codes submitted on the device link page")
.with_unit("{attempt}")
.build()
});
const RESULT: Key = Key::from_static_str("result");
#[derive(Serialize, Deserialize)]
pub struct Params {
@@ -42,6 +55,8 @@ pub(crate) async fn get(
State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
State(site_config): State<SiteConfig>,
State(limiter): State<Limiter>,
requester: RequesterFingerprint,
cookie_jar: CookieJar,
Query(mut query): Query<Params>,
) -> Result<Response, InternalError> {
@@ -63,6 +78,8 @@ pub(crate) async fn get(
&locale,
&templates,
&url_builder,
&limiter,
requester,
cookie_jar,
query,
)
@@ -78,6 +95,8 @@ pub(crate) async fn post(
State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
State(site_config): State<SiteConfig>,
State(limiter): State<Limiter>,
requester: RequesterFingerprint,
cookie_jar: CookieJar,
Form(form): Form<ProtectedForm<Params>>,
) -> Result<Response, InternalError> {
@@ -96,12 +115,15 @@ pub(crate) async fn post(
&locale,
&templates,
&url_builder,
&limiter,
requester,
cookie_jar,
form,
)
.await
}
#[expect(clippy::too_many_arguments)]
async fn handle_code(
rng: &mut BoxRng,
clock: &BoxClock,
@@ -109,6 +131,8 @@ async fn handle_code(
locale: &DataLocale,
templates: &Templates,
url_builder: &UrlBuilder,
limiter: &Limiter,
requester: RequesterFingerprint,
cookie_jar: CookieJar,
params: Params,
) -> Result<Response, InternalError> {
@@ -116,6 +140,25 @@ async fn handle_code(
// If we have a code, find it in the database
if let Some(code) = &params.code {
// Rate-limit how many user codes a single requester can try, to make
// brute-forcing the user code impractical. RFC 8628 section 5.1.
// This is checked before looking the code up, so that guesses beyond the
// allowance are never evaluated.
if let Err(e) = limiter.check_device_code_link(requester) {
tracing::warn!(error = &e as &dyn std::error::Error, "ratelimit exceeded");
USER_CODE_ATTEMPT_COUNTER.add(1, &[KeyValue::new(RESULT, "rate_limited")]);
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(clock, rng);
let ctx = DeviceLinkContext::new()
.with_form_state(form_state.with_error_on_form(FormError::RateLimitExceeded))
.with_csrf(csrf_token.form_value())
.with_language(*locale);
let content = templates.render_device_link(&ctx)?;
return Ok((cookie_jar, Html(content)).into_response());
}
let code = code.to_uppercase();
let grant = repo
.oauth2_device_code_grant()
@@ -128,12 +171,14 @@ async fn handle_code(
if let Some(grant) = grant {
// This is a valid code, redirect to the consent page
// This will in turn redirect to the login page if the user is not logged in
USER_CODE_ATTEMPT_COUNTER.add(1, &[KeyValue::new(RESULT, "success")]);
let destination = url_builder.redirect(&mas_router::DeviceCodeConsent::new(grant.id));
return Ok((cookie_jar, destination).into_response());
}
// The code isn't valid, set an error on the form
USER_CODE_ATTEMPT_COUNTER.add(1, &[KeyValue::new(RESULT, "invalid")]);
form_state = form_state.with_error_on_field(DeviceLinkFormField::Code, FieldError::Invalid);
}
@@ -149,3 +194,147 @@ async fn handle_code(
Ok((cookie_jar, Html(content)).into_response())
}
#[cfg(test)]
mod tests {
use std::net::{IpAddr, Ipv4Addr};
use hyper::{Request, StatusCode, header::CONTENT_TYPE};
use mas_router::{Route, SimpleRoute};
use oauth2_types::{
registration::ClientRegistrationResponse, requests::DeviceAuthorizationResponse,
};
use sqlx::PgPool;
use crate::test_utils::{CookieHelper, RequestBuilderExt, ResponseExt, TestState, setup};
const ALICE: IpAddr = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
const BOB: IpAddr = IpAddr::V4(Ipv4Addr::new(4, 3, 2, 1));
/// Start a device authorization grant and return its user code
async fn get_user_code(state: &TestState) -> String {
let request =
Request::post(mas_router::OAuth2RegistrationEndpoint::PATH).json(serde_json::json!({
"client_uri": "https://example.com/",
"token_endpoint_auth_method": "none",
"grant_types": ["urn:ietf:params:oauth:grant-type:device_code"],
"response_types": [],
}));
let response = state.request(request).await;
response.assert_status(StatusCode::CREATED);
let response: ClientRegistrationResponse = response.json();
let request = Request::post(mas_router::OAuth2DeviceAuthorizationEndpoint::PATH).form(
serde_json::json!({
"client_id": response.client_id,
"scope": "openid",
}),
);
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let response: DeviceAuthorizationResponse = response.json();
response.user_code
}
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_link_rate_limit(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let cookies = CookieHelper::new();
// Render the link page to get a CSRF token. This shouldn't consume any of the
// rate limit allowance, as no code is submitted
let request = Request::get(
mas_router::DeviceCodeLink::default()
.path_and_query()
.as_ref(),
)
.client_ip(ALICE)
.empty();
let request = cookies.with_cookies(request);
let response = state.request(request).await;
cookies.save_cookies(&response);
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
let csrf_token = response
.body()
.split("name=\"csrf\" value=\"")
.nth(1)
.unwrap()
.split('\"')
.next()
.unwrap()
.to_owned();
let request = Request::post(mas_router::DeviceCodeLink::route())
.client_ip(ALICE)
.form(serde_json::json!({
"csrf": csrf_token,
"code": "AAAAAA",
}));
let request = cookies.with_cookies(request);
// The default burst allowance is 10 attempts, which should all be told that the
// code is invalid
for _ in 0..10 {
let response = state.request(request.clone()).await;
response.assert_status(StatusCode::OK);
let body = response.body();
assert!(body.contains(r#"data-error-kind="invalid""#));
assert!(!body.contains("too many requests"));
}
// The next attempt should be rate-limited
let response = state.request(request.clone()).await;
response.assert_status(StatusCode::OK);
let body = response.body();
assert!(!body.contains(r#"data-error-kind="invalid""#));
assert!(body.contains("too many requests"));
// A valid code is refused too: the allowance is checked before the code is
// looked up, so that guesses beyond it are never evaluated
let user_code = get_user_code(&state).await;
let request = Request::get(
mas_router::DeviceCodeLink::with_code(user_code.clone())
.path_and_query()
.as_ref(),
)
.client_ip(ALICE)
.empty();
let request = cookies.with_cookies(request);
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
assert!(response.body().contains("too many requests"));
// Another requester is unaffected and can still use that code
let request = Request::get(
mas_router::DeviceCodeLink::with_code(user_code)
.path_and_query()
.as_ref(),
)
.client_ip(BOB)
.empty();
let response = state.request(request).await;
response.assert_status(StatusCode::SEE_OTHER);
}
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_link_valid_code(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let user_code = get_user_code(&state).await;
// A valid code redirects to the consent page
let request = Request::get(
mas_router::DeviceCodeLink::with_code(user_code)
.path_and_query()
.as_ref(),
)
.client_ip(ALICE)
.empty();
let response = state.request(request).await;
response.assert_status(StatusCode::SEE_OTHER);
}
}
+44
View File
@@ -51,6 +51,12 @@ pub enum EmailAuthenticationLimitedError {
Email(String),
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum DeviceCodeLinkLimitedError {
#[error("Too many device code link attempts for requester {0}")]
Requester(RequesterFingerprint),
}
/// Key used to rate limit requests per requester
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RequesterFingerprint {
@@ -122,6 +128,7 @@ struct LimiterInner {
email_authentication_per_email: KeyedRateLimiter<String>,
email_authentication_emails_per_session: KeyedRateLimiter<Ulid>,
email_authentication_attempt_per_session: KeyedRateLimiter<Ulid>,
device_code_link_per_requester: KeyedRateLimiter<RequesterFingerprint>,
}
impl LimiterInner {
@@ -148,6 +155,7 @@ impl LimiterInner {
email_authentication_attempt_per_session: RateLimiter::keyed(
config.email_authentication.attempt_per_session.to_quota()?,
),
device_code_link_per_requester: RateLimiter::keyed(config.device_code_link.to_quota()?),
})
}
}
@@ -193,6 +201,7 @@ impl Limiter {
this.inner
.email_authentication_attempt_per_session
.retain_recent();
this.inner.device_code_link_per_requester.retain_recent();
interval.tick().await;
}
@@ -325,6 +334,24 @@ impl Limiter {
.check_key(&authentication.id)
.map_err(|_| EmailAuthenticationLimitedError::Authentication(authentication.id))
}
/// Check if a user code can be submitted on the device link page
///
/// This protects against brute-forcing the user code of a device code
/// grant, as described in RFC 8628 section 5.1.
///
/// # Errors
///
/// Returns an error if the operation is rate limited.
pub fn check_device_code_link(
&self,
requester: RequesterFingerprint,
) -> Result<(), DeviceCodeLinkLimitedError> {
self.inner
.device_code_link_per_requester
.check_key(&requester)
.map_err(|_| DeviceCodeLinkLimitedError::Requester(requester))
}
}
#[cfg(test)]
@@ -401,4 +428,21 @@ mod tests {
// The other account isn't rate-limited
assert!(limiter.check_password(requesters[603], &bob).is_ok());
}
#[test]
fn test_device_code_link_limiter() {
let limiter = Limiter::new(&RateLimitingConfig::default()).unwrap();
let alice = RequesterFingerprint::new([1, 2, 3, 4].into());
let bob = RequesterFingerprint::new([4, 3, 2, 1].into());
// The default burst allowance is 10 attempts per requester
for _ in 0..10 {
assert!(limiter.check_device_code_link(alice).is_ok());
}
assert!(limiter.check_device_code_link(alice).is_err());
// Another requester is unaffected
assert!(limiter.check_device_code_link(bob).is_ok());
}
}
+12
View File
@@ -2021,6 +2021,18 @@
"$ref": "#/definitions/EmailauthenticationRateLimitingConfig"
}
]
},
"device_code_link": {
"description": "Controls how many user code verification attempts are permitted\nbased on source IP address, when linking a device through the\nDevice Authorization Grant.\nThis can protect against brute-forcing the user code.",
"default": {
"burst": 10,
"per_second": 0.016666666666666666
},
"allOf": [
{
"$ref": "#/definitions/RateLimiterConfiguration"
}
]
}
}
},
+8
View File
@@ -640,6 +640,14 @@ rate_limiting:
attempt_per_session:
burst: 10
per_second: 0.016666
# Limits how many user codes can be submitted on the device link page,
# based on source IP address.
# This protects against brute-forcing the user code of the
# Device Authorization Grant.
device_code_link:
burst: 10
per_second: 0.016666
```
## `telemetry`
+8
View File
@@ -22,6 +22,14 @@ Please see LICENSE files in the repository root for full details.
<section class="flex flex-col gap-5">
<form method="POST" class="cpd-form-root">
{% if form_state.errors is not empty %}
{% for error in form_state.errors %}
<div class="text-critical font-medium">
{{ errors.form_error_message(error=error) }}
</div>
{% endfor %}
{% endif %}
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
{% call(f) field.field(label=_("mas.device_code_link.verification_code"), name="code", class="mb-4 self-center", form_state=form_state) %}
<div class="cpd-mfa-container">
+3 -3
View File
@@ -6,11 +6,11 @@
},
"cancel": "Cancel",
"@cancel": {
"context": "pages/consent.html:81:11-29, pages/device_consent.html:179:13-31, pages/device_link.html:45:33-51, pages/policy_violation.html:70:15-33, pages/reauth.html:37:13-31"
"context": "pages/consent.html:81:11-29, pages/device_consent.html:179:13-31, pages/device_link.html:53:33-51, pages/policy_violation.html:70:15-33, pages/reauth.html:37:13-31"
},
"continue": "Continue",
"@continue": {
"context": "form_post.html:26:28-48, pages/consent.html:71:28-48, pages/device_link.html:42:28-48, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:80:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/registration_token.html:41:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:52:28-48"
"context": "form_post.html:26:28-48, pages/consent.html:71:28-48, pages/device_link.html:50:28-48, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:80:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/registration_token.html:41:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:52:28-48"
},
"create_account": "Create Account",
"@create_account": {
@@ -234,7 +234,7 @@
},
"verification_code": "Verification code",
"@verification_code": {
"context": "pages/device_link.html:26:35-78",
"context": "pages/device_link.html:34:35-78",
"description": "On the device link page, label of the text field where the user enters the verification code shown on the other device."
}
},