Skip to content

Commit 02086f8

Browse files
authored
Merge pull request #2884 from OpenC3/maint/login
API rate limiting
2 parents 31339bb + 6fb2a12 commit 02086f8

File tree

9 files changed

+186
-8
lines changed

9 files changed

+186
-8
lines changed

.env

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ OPENC3_SR_REDIS_PASSWORD=scriptrunnerpassword
5555
OPENC3_SR_BUCKET_USERNAME=scriptrunnerbucket
5656
OPENC3_SR_BUCKET_PASSWORD=scriptrunnerbucketpassword
5757
OPENC3_SERVICE_PASSWORD=openc3service
58+
# Rate limiting for authentication endpoints. Uncomment to change from defaults.
59+
# In COSMOS Enterprise, use Keycloak to configure this instead.
60+
# This prevents more than 10 failed password attempts within 120 seconds.
61+
# OPENC3_AUTH_RATE_LIMIT_TO=10
62+
# OPENC3_AUTH_RATE_LIMIT_WITHIN=120
5863
# Build and repository settings
5964
ALPINE_VERSION=3.22
6065
ALPINE_BUILD=3

openc3-cosmos-cmd-tlm-api/app/controllers/auth_controller.rb

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@
1919
require 'openc3/models/auth_model'
2020

2121
class AuthController < ApplicationController
22+
MAX_BAD_ATTEMPTS = ENV.fetch('OPENC3_AUTH_RATE_LIMIT_TO', '10').to_i
23+
BAD_ATTEMPTS_WINDOW = ENV.fetch('OPENC3_AUTH_RATE_LIMIT_WITHIN', '120').to_i
24+
25+
@@user_bad_attempts_count = 0
26+
@@user_bad_attempts_first_time = nil
27+
@@user_bad_attempts_mutex = Mutex.new
28+
29+
@@service_bad_attempts_count = 0
30+
@@service_bad_attempts_first_time = nil
31+
@@service_bad_attempts_mutex = Mutex.new
32+
2233
def token_exists
2334
result = OpenC3::AuthModel.set?
2435
render json: {
@@ -27,10 +38,16 @@ def token_exists
2738
end
2839

2940
def verify
41+
if user_rate_limited?
42+
head :too_many_requests
43+
return
44+
end
45+
3046
begin
3147
if OpenC3::AuthModel.verify_no_service(params[:password], no_password: false)
3248
render :plain => OpenC3::AuthModel.generate_session()
3349
else
50+
record_user_bad_attempt
3451
head :unauthorized
3552
end
3653
rescue StandardError => e
@@ -40,10 +57,16 @@ def verify
4057
end
4158

4259
def verify_service
60+
if service_rate_limited?
61+
head :too_many_requests
62+
return
63+
end
64+
4365
begin
4466
if OpenC3::AuthModel.verify(params[:password], service_only: true)
4567
render :plain => OpenC3::AuthModel.generate_session()
4668
else
69+
record_service_bad_attempt
4770
head :unauthorized
4871
end
4972
rescue StandardError => e
@@ -53,14 +76,84 @@ def verify_service
5376
end
5477

5578
def set
79+
if user_rate_limited?
80+
head :too_many_requests
81+
return
82+
end
83+
5684
begin
5785
# Set throws an exception if it fails for any reason
5886
OpenC3::AuthModel.set(params[:password], params[:old_password])
5987
OpenC3::Logger.info("Password changed", user: username())
6088
render :plain => OpenC3::AuthModel.generate_session()
6189
rescue StandardError => e
90+
if e.message == "old_password incorrect"
91+
record_user_bad_attempt
92+
end
6293
log_error(e)
6394
render json: { status: 'error', message: e.message, type: e.class }, status: 500
6495
end
6596
end
97+
98+
private
99+
100+
# Checks to see if the user password has been rate limited due to bad attempts
101+
def user_rate_limited?
102+
@@user_bad_attempts_mutex.synchronize do
103+
time = Time.now
104+
105+
# Reset counter if window has expired
106+
if @@user_bad_attempts_first_time && (time - @@user_bad_attempts_first_time) > BAD_ATTEMPTS_WINDOW
107+
@@user_bad_attempts_count = 0
108+
@@user_bad_attempts_first_time = nil
109+
end
110+
111+
return @@user_bad_attempts_count >= MAX_BAD_ATTEMPTS
112+
end
113+
end
114+
115+
# Initializes or increments the bad attempt counter for the user password
116+
def record_user_bad_attempt
117+
@@user_bad_attempts_mutex.synchronize do
118+
time = Time.now
119+
120+
# Start new window if this is the first attempt or window expired
121+
if @@user_bad_attempts_first_time.nil? || (time - @@user_bad_attempts_first_time) > BAD_ATTEMPTS_WINDOW
122+
@@user_bad_attempts_count = 1
123+
@@user_bad_attempts_first_time = time
124+
else
125+
@@user_bad_attempts_count += 1
126+
end
127+
end
128+
end
129+
130+
# Checks to see if the service password has been rate limited due to bad attempts
131+
def service_rate_limited?
132+
@@service_bad_attempts_mutex.synchronize do
133+
time = Time.now
134+
135+
# Reset counter if window has expired
136+
if @@service_bad_attempts_first_time && (time - @@service_bad_attempts_first_time) > BAD_ATTEMPTS_WINDOW
137+
@@service_bad_attempts_count = 0
138+
@@service_bad_attempts_first_time = nil
139+
end
140+
141+
return @@service_bad_attempts_count >= MAX_BAD_ATTEMPTS
142+
end
143+
end
144+
145+
# Initializes or increments the bad attempt counter for the service password
146+
def record_service_bad_attempt
147+
@@service_bad_attempts_mutex.synchronize do
148+
time = Time.now
149+
150+
# Start new window if this is the first attempt or window expired
151+
if @@service_bad_attempts_first_time.nil? || (time - @@service_bad_attempts_first_time) > BAD_ATTEMPTS_WINDOW
152+
@@service_bad_attempts_count = 1
153+
@@service_bad_attempts_first_time = time
154+
else
155+
@@service_bad_attempts_count += 1
156+
end
157+
end
158+
end
66159
end

openc3-cosmos-cmd-tlm-api/spec/controllers/auth_controller_spec.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,46 @@
8282
expect(response).to have_http_status(:ok)
8383
end
8484
end
85+
86+
describe "rate limiting" do
87+
# Actually testing that rate limiting is enforced is done in Playwright
88+
# because the bad attempt counters in the controller are shared across
89+
# all requests/tests. But we can ensure that the config is read and that
90+
# successful attempts don't get rate limited.
91+
92+
it "uses default rate limit values from environment" do
93+
expect(ENV['OPENC3_AUTH_RATE_LIMIT_TO']).to eq('10')
94+
expect(ENV['OPENC3_AUTH_RATE_LIMIT_WITHIN']).to eq('120')
95+
end
96+
97+
it "respects custom rate limit values from environment" do
98+
original_to = ENV.fetch('OPENC3_AUTH_RATE_LIMIT_TO', '10')
99+
original_within = ENV.fetch('OPENC3_AUTH_RATE_LIMIT_WITHIN', '120')
100+
101+
begin
102+
ENV['OPENC3_AUTH_RATE_LIMIT_TO'] = '5'
103+
ENV['OPENC3_AUTH_RATE_LIMIT_WITHIN'] = '60'
104+
105+
load Rails.root.join('app', 'controllers', 'auth_controller.rb')
106+
107+
expect(ENV['OPENC3_AUTH_RATE_LIMIT_TO']).to eq('5')
108+
expect(ENV['OPENC3_AUTH_RATE_LIMIT_WITHIN']).to eq('60')
109+
ensure
110+
ENV['OPENC3_AUTH_RATE_LIMIT_TO'] = original_to
111+
ENV['OPENC3_AUTH_RATE_LIMIT_WITHIN'] = original_within
112+
load Rails.root.join('app', 'controllers', 'auth_controller.rb')
113+
end
114+
end
115+
116+
it "does not rate limit successful password attempts" do
117+
# Note: testing rate limit for bad attempts is done in Playwright
118+
post :set, params: { password: 'PASSWORD' }
119+
expect(response).to have_http_status(:ok)
120+
121+
20.times do
122+
post :verify, params: { password: 'PASSWORD' }
123+
expect(response).to have_http_status(:ok)
124+
end
125+
end
126+
end
85127
end

openc3-cosmos-cmd-tlm-api/spec/spec_helper.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@
7676
ENV['OPENC3_BUCKET_PASSWORD'] = 'openc3bucketpassword'
7777
ENV['OPENC3_SCOPE'] = 'DEFAULT'
7878
ENV['OPENC3_CLOUD'] = 'local'
79+
ENV['OPENC3_AUTH_RATE_LIMIT_TO'] ||= '10'
80+
ENV['OPENC3_AUTH_RATE_LIMIT_WITHIN'] ||= '120'
7981

8082
$openc3_scope = ENV['OPENC3_SCOPE']
8183
$openc3_token = ENV['OPENC3_API_PASSWORD']

openc3-cosmos-init/plugins/packages/openc3-vue-common/src/tools/base/Login.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ export default {
169169
.catch((error) => {
170170
if (error?.status === 401) {
171171
this.alert = 'Incorrect password'
172+
} else if (error?.status === 429) {
173+
this.alert = 'Please try again later'
172174
} else if (
173175
error?.response?.data?.message === 'invalid password hash'
174176
) {

openc3/lib/openc3/models/auth_model.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def self.verify_no_service(token, no_password: true)
8484

8585
# Check stored password hash
8686
pw_hash = Store.get(PRIMARY_KEY)
87-
raise "invalid password hash" unless pw_hash.start_with?("$argon2") # Catch users who didn't run the migration utility when upgrading to COSMOS 7
87+
raise "invalid password hash" if pw_hash.nil? || !pw_hash.start_with?("$argon2") # Catch users who didn't run the migration utility when upgrading to COSMOS 7
8888
@@pw_hash_cache = pw_hash
8989
@@pw_hash_cache_time = time
9090
return Argon2::Password.verify_password(token, @@pw_hash_cache)

playwright/tests/auth.p.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
# Copyright 2026 OpenC3, Inc.
3+
# All Rights Reserved.
4+
#
5+
# This program is distributed in the hope that it will be useful,
6+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
7+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
8+
# See LICENSE.md for more details.
9+
*/
10+
11+
import { test, expect } from '@playwright/test'
12+
13+
test.describe('Auth API', () => {
14+
test('verifies rate limiting is enforced', async ({ request }) => {
15+
if (process.env.ENTERPRISE === '1') {
16+
// Rate limiting handled by Keycloak in Enterprise
17+
return
18+
}
19+
20+
const maxRequests = Number.parseInt(process.env.OPENC3_AUTH_RATE_LIMIT_TO || '10')
21+
22+
let gotRateLimited = false
23+
for (let i = 0; i <= maxRequests; i++) {
24+
const response = await request.post('/openc3-api/auth/verify', {
25+
data: { password: 'whatever' }
26+
})
27+
28+
if (response.status() === 429) {
29+
gotRateLimited = true
30+
break
31+
}
32+
}
33+
34+
expect(gotRateLimited).toBe(true)
35+
})
36+
})

playwright/tests/data-extractor.p.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,12 +161,12 @@ test('cancels a process', async ({ page, utils }) => {
161161

162162
test('adds an entire target', async ({ page, utils }) => {
163163
await utils.addTargetPacketItem('INST')
164-
await expect(page.getByText('1-20 of 278')).toBeVisible()
164+
await expect(page.getByText('1-20 of 279')).toBeVisible()
165165
})
166166

167167
test('adds an entire packet', async ({ page, utils }) => {
168168
await utils.addTargetPacketItem('INST', 'HEALTH_STATUS')
169-
await expect(page.getByText('1-20 of 39')).toBeVisible()
169+
await expect(page.getByText('1-20 of 40')).toBeVisible()
170170
})
171171

172172
test('add, edits, deletes items', async ({ page, utils }) => {

playwright/tests/data-viewer.p.spec.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,9 @@ test('saves, opens, and resets the configuration', async ({ page, utils }) => {
4747

4848
// Add a new component with a different type
4949
await page.locator('[data-test=new-tab]').click()
50-
await page
51-
.getByRole('combobox')
52-
.filter({ hasText: 'COSMOS Packet Raw/Decom' })
53-
.click()
54-
await page.getByText('Current Time').click()
50+
await page.locator('[data-test=select-component]').click()
51+
await page.getByRole('option', { name: 'Current Time' }).click()
52+
await utils.sleep(100) // Wait for TPI chooser to be clickable
5553
await utils.selectTargetPacketItem('INST', 'HEALTH_STATUS')
5654
await page.locator('[data-test=select-send]').click() // add the packet to the list
5755
await page.locator('[data-test=add-component]').click()

0 commit comments

Comments
 (0)