SECURITY: Limit name field length of TOTP authenticators and security keys

This commit is contained in:
OsamaSayegh 2023-08-24 09:27:38 +03:00 committed by Roman Rizzi
parent d3c29c02b9
commit 48316d75cd
No known key found for this signature in database
GPG Key ID: 64024A71CE7330D3
21 changed files with 309 additions and 4 deletions

View File

@ -27,6 +27,7 @@
@value={{this.securityKeyName}} @value={{this.securityKeyName}}
id="security-key-name" id="security-key-name"
placeholder="security key name" placeholder="security key name"
maxlength={{this.maxSecondFactorNameLength}}
/> />
</div> </div>
</div> </div>

View File

@ -8,6 +8,7 @@ import I18n from "I18n";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { MAX_SECOND_FACTOR_NAME_LENGTH } from "discourse/models/user";
export default class SecondFactorAddSecurityKey extends Component { export default class SecondFactorAddSecurityKey extends Component {
@service capabilities; @service capabilities;
@ -16,6 +17,8 @@ export default class SecondFactorAddSecurityKey extends Component {
@tracked errorMessage = null; @tracked errorMessage = null;
@tracked securityKeyName; @tracked securityKeyName;
maxSecondFactorNameLength = MAX_SECOND_FACTOR_NAME_LENGTH;
get webauthnUnsupported() { get webauthnUnsupported() {
return !isWebauthnSupported(); return !isWebauthnSupported();
} }
@ -30,7 +33,7 @@ export default class SecondFactorAddSecurityKey extends Component {
} else { } else {
key = "user.second_factor.security_key.default_name"; key = "user.second_factor.security_key.default_name";
} }
this.securityKeyName = key; this.securityKeyName = I18n.t(key);
this.loading = true; this.loading = true;
this.args.model.secondFactor this.args.model.secondFactor

View File

@ -46,6 +46,7 @@
}}</label> }}</label>
<div class="controls"> <div class="controls">
<SecondFactorInput <SecondFactorInput
maxlength={{this.maxSecondFactorNameLength}}
@value={{this.secondFactorName}} @value={{this.secondFactorName}}
@inputId="second-factor-name" @inputId="second-factor-name"
@placeholder={{i18n "user.second_factor.totp.default_name"}} @placeholder={{i18n "user.second_factor.totp.default_name"}}

View File

@ -2,6 +2,7 @@ import Component from "@glimmer/component";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import I18n from "I18n"; import I18n from "I18n";
import { MAX_SECOND_FACTOR_NAME_LENGTH } from "discourse/models/user";
export default class SecondFactorAddTotp extends Component { export default class SecondFactorAddTotp extends Component {
@tracked loading = false; @tracked loading = false;
@ -11,6 +12,8 @@ export default class SecondFactorAddTotp extends Component {
@tracked errorMessage; @tracked errorMessage;
@tracked secondFactorToken; @tracked secondFactorToken;
maxSecondFactorNameLength = MAX_SECOND_FACTOR_NAME_LENGTH;
@action @action
totpRequested() { totpRequested() {
this.args.model.secondFactor this.args.model.secondFactor

View File

@ -10,6 +10,7 @@
}}</label> }}</label>
<Input <Input
name="security-key-name" name="security-key-name"
maxlength={{this.maxSecondFactorNameLength}}
@type="text" @type="text"
@value={{@model.securityKey.name}} @value={{@model.securityKey.name}}
/> />

View File

@ -1,10 +1,13 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { MAX_SECOND_FACTOR_NAME_LENGTH } from "discourse/models/user";
export default class SecondFactorEditSecurityKey extends Component { export default class SecondFactorEditSecurityKey extends Component {
@tracked loading = false; @tracked loading = false;
maxSecondFactorNameLength = MAX_SECOND_FACTOR_NAME_LENGTH;
@action @action
editSecurityKey() { editSecurityKey() {
this.loading = true; this.loading = true;

View File

@ -9,6 +9,7 @@
}}</label> }}</label>
<Input <Input
name="authenticator-name" name="authenticator-name"
maxlength={{this.maxSecondFactorNameLength}}
@type="text" @type="text"
@value={{@model.secondFactor.name}} @value={{@model.secondFactor.name}}
/> />

View File

@ -1,10 +1,13 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { MAX_SECOND_FACTOR_NAME_LENGTH } from "discourse/models/user";
export default class SecondFactorEdit extends Component { export default class SecondFactorEdit extends Component {
@tracked loading = false; @tracked loading = false;
maxSecondFactorNameLength = MAX_SECOND_FACTOR_NAME_LENGTH;
@action @action
editSecondFactor() { editSecondFactor() {
this.loading = true; this.loading = true;

View File

@ -11,4 +11,5 @@
@autofocus="autofocus" @autofocus="autofocus"
@placeholder={{this.placeholder}} @placeholder={{this.placeholder}}
@input={{action "onInput"}} @input={{action "onInput"}}
...attributes
/> />

View File

@ -49,6 +49,8 @@ export const SECOND_FACTOR_METHODS = {
SECURITY_KEY: 3, SECURITY_KEY: 3,
}; };
export const MAX_SECOND_FACTOR_NAME_LENGTH = 300;
const TEXT_SIZE_COOKIE_NAME = "text_size"; const TEXT_SIZE_COOKIE_NAME = "text_size";
const COOKIE_EXPIRY_DAYS = 365; const COOKIE_EXPIRY_DAYS = 365;

View File

@ -1545,6 +1545,11 @@ class UsersController < ApplicationController
end end
def create_second_factor_security_key def create_second_factor_security_key
if current_user.all_security_keys.count >= UserSecurityKey::MAX_KEYS_PER_USER
render_json_error(I18n.t("login.too_many_security_keys"), status: 422)
return
end
challenge_session = Webauthn.stage_challenge(current_user, secure_session) challenge_session = Webauthn.stage_challenge(current_user, secure_session)
render json: render json:
success_json.merge( success_json.merge(

View File

@ -96,6 +96,7 @@ class User < ActiveRecord::Base
has_many :directory_items has_many :directory_items
has_many :email_logs has_many :email_logs
has_many :security_keys, -> { where(enabled: true) }, class_name: "UserSecurityKey" has_many :security_keys, -> { where(enabled: true) }, class_name: "UserSecurityKey"
has_many :all_security_keys, class_name: "UserSecurityKey"
has_many :badges, through: :user_badges has_many :badges, through: :user_badges
has_many :default_featured_user_badges, has_many :default_featured_user_badges,

View File

@ -2,6 +2,10 @@
class UserSecondFactor < ActiveRecord::Base class UserSecondFactor < ActiveRecord::Base
include SecondFactorManager include SecondFactorManager
MAX_TOTPS_PER_USER = 50
MAX_NAME_LENGTH = 300
belongs_to :user belongs_to :user
scope :backup_codes, -> { where(method: UserSecondFactor.methods[:backup_codes], enabled: true) } scope :backup_codes, -> { where(method: UserSecondFactor.methods[:backup_codes], enabled: true) }
@ -10,6 +14,10 @@ class UserSecondFactor < ActiveRecord::Base
scope :all_totps, -> { where(method: UserSecondFactor.methods[:totp]) } scope :all_totps, -> { where(method: UserSecondFactor.methods[:totp]) }
validates :name, length: { maximum: MAX_NAME_LENGTH }, if: :name_changed?
validate :count_per_user_does_not_exceed_limit, on: :create
def self.methods def self.methods
@methods ||= Enum.new(totp: 1, backup_codes: 2, security_key: 3) @methods ||= Enum.new(totp: 1, backup_codes: 2, security_key: 3)
end end
@ -21,6 +29,16 @@ class UserSecondFactor < ActiveRecord::Base
def totp_provisioning_uri def totp_provisioning_uri
totp_object.provisioning_uri(user.email) totp_object.provisioning_uri(user.email)
end end
private
def count_per_user_does_not_exceed_limit
if self.method == UserSecondFactor.methods[:totp]
if self.class.where(method: self.method, user_id: self.user_id).count >= MAX_TOTPS_PER_USER
errors.add(:base, I18n.t("login.too_many_authenticators"))
end
end
end
end end
# == Schema Information # == Schema Information
@ -35,7 +53,7 @@ end
# last_used :datetime # last_used :datetime
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# name :string # name :string(300)
# #
# Indexes # Indexes
# #

View File

@ -2,13 +2,26 @@
class UserSecurityKey < ActiveRecord::Base class UserSecurityKey < ActiveRecord::Base
belongs_to :user belongs_to :user
MAX_KEYS_PER_USER = 50
MAX_NAME_LENGTH = 300
scope :second_factors, scope :second_factors,
-> { where(factor_type: UserSecurityKey.factor_types[:second_factor], enabled: true) } -> { where(factor_type: UserSecurityKey.factor_types[:second_factor], enabled: true) }
validates :name, length: { maximum: MAX_NAME_LENGTH }, if: :name_changed?
validate :count_per_user_does_not_exceed_limit, on: :create
def self.factor_types def self.factor_types
@factor_types ||= Enum.new(second_factor: 0, first_factor: 1, multi_factor: 2) @factor_types ||= Enum.new(second_factor: 0, first_factor: 1, multi_factor: 2)
end end
private
def count_per_user_does_not_exceed_limit
if UserSecurityKey.where(user_id: self.user_id).count >= MAX_KEYS_PER_USER
errors.add(:base, I18n.t("login.too_many_security_keys"))
end
end
end end
# == Schema Information # == Schema Information
@ -21,7 +34,7 @@ end
# public_key :string not null # public_key :string not null
# factor_type :integer default(0), not null # factor_type :integer default(0), not null
# enabled :boolean default(TRUE), not null # enabled :boolean default(TRUE), not null
# name :string not null # name :string(300) not null
# last_used :datetime # last_used :datetime
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null

View File

@ -2675,6 +2675,8 @@ en:
invalid_security_key: "Invalid security key." invalid_security_key: "Invalid security key."
missing_second_factor_name: "Please provide a name." missing_second_factor_name: "Please provide a name."
missing_second_factor_code: "Please provide a code." missing_second_factor_code: "Please provide a code."
too_many_authenticators: "Sorry, you can't have more than 50 authenticators. Please remove an existing one and try again."
too_many_security_keys: "Sorry, you can't have more than 50 security keys. Please remove an existing one and try again."
second_factor_toggle: second_factor_toggle:
totp: "Use an authenticator app or security key instead" totp: "Use an authenticator app or security key instead"
backup_code: "Use a backup code instead" backup_code: "Use a backup code instead"

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class AddLimitToUserSecondFactorName < ActiveRecord::Migration[7.0]
def up
execute(<<~SQL)
UPDATE user_second_factors
SET name = LEFT(name, 300)
WHERE name IS NOT NULL AND LENGTH(name) > 300
SQL
change_column :user_second_factors, :name, :string, limit: 300
end
def down
raise ActiveRecord::IrreversibleMigration
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class AddLimitToUserSecurityKeyName < ActiveRecord::Migration[7.0]
def up
execute(<<~SQL)
UPDATE user_security_keys
SET name = LEFT(name, 300)
WHERE name IS NOT NULL AND LENGTH(name) > 300
SQL
change_column :user_security_keys, :name, :string, limit: 300
end
def down
raise ActiveRecord::IrreversibleMigration
end
end

View File

@ -109,7 +109,7 @@ module Webauthn
# then register the new credential with the account that was denoted in options.user, by # then register the new credential with the account that was denoted in options.user, by
# associating it with the credentialId and credentialPublicKey in the attestedCredentialData # associating it with the credentialId and credentialPublicKey in the attestedCredentialData
# in authData, as appropriate for the Relying Party's system. # in authData, as appropriate for the Relying Party's system.
UserSecurityKey.create( UserSecurityKey.create!(
user: @current_user, user: @current_user,
credential_id: encoded_credential_id, credential_id: encoded_credential_id,
public_key: endcoded_public_key, public_key: endcoded_public_key,

View File

@ -1,10 +1,83 @@
# frozen_string_literal: true # frozen_string_literal: true
RSpec.describe UserSecondFactor do RSpec.describe UserSecondFactor do
fab!(:user) { Fabricate(:user) }
describe ".methods" do describe ".methods" do
it "should retain the right order" do it "should retain the right order" do
expect(described_class.methods[:totp]).to eq(1) expect(described_class.methods[:totp]).to eq(1)
expect(described_class.methods[:backup_codes]).to eq(2) expect(described_class.methods[:backup_codes]).to eq(2)
end end
end end
describe "name length validation" do
it "allows the name to be nil" do
Fabricate(:user_second_factor_totp, user: user, name: nil)
end
it "doesn't allow the name to be longer than the limit" do
expect do
Fabricate(
:user_second_factor_totp,
user: user,
name: "a" * (described_class::MAX_NAME_LENGTH + 1),
)
end.to raise_error(ActiveRecord::RecordInvalid) do |error|
expect(error.message).to include(
I18n.t("activerecord.errors.messages.too_long", count: described_class::MAX_NAME_LENGTH),
)
end
end
it "allows a name that is equal to or less than the limit" do
expect do
Fabricate(
:user_second_factor_totp,
user: user,
name: "a" * described_class::MAX_NAME_LENGTH,
)
end.not_to raise_error
end
end
describe "per-user count validation" do
it "doesn't allow a user to have more authenticators than the limit allows" do
stub_const(UserSecondFactor, "MAX_TOTPS_PER_USER", 1) do
Fabricate(:user_second_factor_totp, user: user)
expect do Fabricate(:user_second_factor_totp, user: user) end.to raise_error(
ActiveRecord::RecordInvalid,
) do |error|
expect(error.message).to include(I18n.t("login.too_many_authenticators"))
end
end
end
it "doesn't count backup codes in the authenticators limit" do
user.generate_backup_codes
expect(user.user_second_factors.backup_codes.count).to eq(10)
stub_const(UserSecondFactor, "MAX_TOTPS_PER_USER", 1) do
Fabricate(:user_second_factor_totp, user: user)
expect do Fabricate(:user_second_factor_totp, user: user) end.to raise_error(
ActiveRecord::RecordInvalid,
) do |error|
expect(error.message).to include(I18n.t("login.too_many_authenticators"))
end
end
end
it "doesn't count authenticators from other users" do
another_user = Fabricate(:user)
Fabricate(:user_second_factor_totp, user: another_user)
stub_const(UserSecondFactor, "MAX_TOTPS_PER_USER", 1) do
Fabricate(:user_second_factor_totp, user: user)
expect do Fabricate(:user_second_factor_totp, user: user) end.to raise_error(
ActiveRecord::RecordInvalid,
) do |error|
expect(error.message).to include(I18n.t("login.too_many_authenticators"))
end
end
end
end
end end

View File

@ -0,0 +1,58 @@
# frozen_string_literal: true
RSpec.describe UserSecurityKey do
fab!(:user) { Fabricate(:user) }
describe "name length validation" do
it "doesn't allow the name to be longer than the limit" do
expect do
Fabricate(
:user_security_key_with_random_credential,
user: user,
name: "b" * (described_class::MAX_NAME_LENGTH + 1),
)
end.to raise_error(ActiveRecord::RecordInvalid) do |error|
expect(error.message).to include(
I18n.t("activerecord.errors.messages.too_long", count: described_class::MAX_NAME_LENGTH),
)
end
end
it "allows a name that's under the limit" do
expect do
Fabricate(
:user_security_key_with_random_credential,
user: user,
name: "b" * described_class::MAX_NAME_LENGTH,
)
end.not_to raise_error
end
end
describe "per-user count validation" do
it "doesn't allow a user to have more security keys than the limit allows" do
stub_const(UserSecurityKey, "MAX_KEYS_PER_USER", 1) do
Fabricate(:user_security_key_with_random_credential, user: user)
expect do
Fabricate(:user_security_key_with_random_credential, user: user)
end.to raise_error(ActiveRecord::RecordInvalid) do |error|
expect(error.message).to include(I18n.t("login.too_many_security_keys"))
end
end
end
it "doesn't count security keys from other users" do
another_user = Fabricate(:user)
Fabricate(:user_security_key_with_random_credential, user: another_user)
stub_const(UserSecurityKey, "MAX_KEYS_PER_USER", 1) do
Fabricate(:user_security_key_with_random_credential, user: user)
expect do
Fabricate(:user_security_key_with_random_credential, user: user)
end.to raise_error(ActiveRecord::RecordInvalid) do |error|
expect(error.message).to include(I18n.t("login.too_many_security_keys"))
end
end
end
end
end

View File

@ -5475,6 +5475,46 @@ RSpec.describe UsersController do
expect(response.parsed_body["error"]).to eq(I18n.t("login.missing_second_factor_code")) expect(response.parsed_body["error"]).to eq(I18n.t("login.missing_second_factor_code"))
end end
end end
it "doesn't allow creating too many TOTPs" do
Fabricate(:user_second_factor_totp, user: user1)
create_totp
staged_totp_key = read_secure_session["staged-totp-#{user1.id}"]
token = ROTP::TOTP.new(staged_totp_key).now
stub_const(UserSecondFactor, "MAX_TOTPS_PER_USER", 1) do
post "/users/enable_second_factor_totp.json",
params: {
name: "test",
second_factor_token: token,
}
end
expect(response.status).to eq(422)
expect(response.parsed_body["errors"]).to include(I18n.t("login.too_many_authenticators"))
expect(user1.user_second_factors.count).to eq(1)
end
it "doesn't allow the TOTP name to exceed the limit" do
create_totp
staged_totp_key = read_secure_session["staged-totp-#{user1.id}"]
token = ROTP::TOTP.new(staged_totp_key).now
post "/users/enable_second_factor_totp.json",
params: {
name: "a" * (UserSecondFactor::MAX_NAME_LENGTH + 1),
second_factor_token: token,
}
expect(response.status).to eq(422)
expect(response.parsed_body["errors"]).to include(
"Name is too long (maximum is 300 characters)",
)
expect(user1.user_second_factors.count).to eq(0)
end
end end
describe "#update_second_factor" do describe "#update_second_factor" do
@ -5650,6 +5690,13 @@ RSpec.describe UsersController do
expect(response_parsed["supported_algorithms"]).to eq(::Webauthn::SUPPORTED_ALGORITHMS) expect(response_parsed["supported_algorithms"]).to eq(::Webauthn::SUPPORTED_ALGORITHMS)
end end
it "doesn't create a challenge if the user has the maximum number allowed of security keys" do
Fabricate(:user_security_key_with_random_credential, user: user1)
stub_const(UserSecurityKey, "MAX_KEYS_PER_USER", 1) { create_second_factor_security_key }
expect(response.status).to eq(422)
expect(response.parsed_body["errors"]).to include(I18n.t("login.too_many_security_keys"))
end
context "if the user has security key credentials already" do context "if the user has security key credentials already" do
fab!(:user_security_key) { Fabricate(:user_security_key_with_random_credential, user: user1) } fab!(:user_security_key) { Fabricate(:user_security_key_with_random_credential, user: user1) }
@ -5679,6 +5726,43 @@ RSpec.describe UsersController do
) )
expect(user1.security_keys.last.name).to eq(valid_security_key_create_post_data[:name]) expect(user1.security_keys.last.name).to eq(valid_security_key_create_post_data[:name])
end end
it "doesn't allow creating too many security keys" do
simulate_localhost_webauthn_challenge
create_second_factor_security_key
_response_parsed = response.parsed_body
Fabricate(:user_security_key_with_random_credential, user: user1)
stub_const(UserSecurityKey, "MAX_KEYS_PER_USER", 1) do
post "/u/register_second_factor_security_key.json",
params: valid_security_key_create_post_data
end
expect(response.status).to eq(422)
expect(response.parsed_body["errors"]).to include(I18n.t("login.too_many_security_keys"))
expect(user1.security_keys.count).to eq(1)
end
it "doesn't allow the security key name to exceed the limit" do
simulate_localhost_webauthn_challenge
create_second_factor_security_key
_response_parsed = response.parsed_body
post "/u/register_second_factor_security_key.json",
params:
valid_security_key_create_post_data.merge(
name: "a" * (UserSecurityKey::MAX_NAME_LENGTH + 1),
)
expect(response.status).to eq(422)
expect(response.parsed_body["errors"]).to include(
"Name is too long (maximum is 300 characters)",
)
expect(user1.security_keys.count).to eq(0)
end
end end
context "when the creation parameters are invalid" do context "when the creation parameters are invalid" do