Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 46 additions & 26 deletions addons/odusite_base/models/odusite_rate_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
event registration / auth). One row per key with an atomic
``INSERT ... ON CONFLICT`` counter: concurrent requests cannot lose updates
(unlike a single JSON ``ir.config_parameter``) and each key is an isolated row,
so there is no global write hotspot. The client IP is the Cloudflare-forwarded
``CF-Connecting-IP`` (the site forwards it), not ``remote_addr`` — behind a
tunnel the latter is the proxy address, which would bucket every visitor
together.
so there is no global write hotspot. The site forwards Cloudflare's client IP
in ``X-Odusite-Client-IP`` because Cloudflare replaces ``CF-Connecting-IP`` on
cross-zone Worker subrequests.
"""

import ipaddress
import time

from odoo import api, fields, models
Expand All @@ -30,19 +30,26 @@ class OdusiteRateLimit(models.Model):

key = fields.Char(required=True)
window_start = fields.Integer(required=True)
expires_at = fields.Integer(required=True, default=0)
hits = fields.Integer(default=0)

_key_unique = models.Constraint('UNIQUE(key)', 'One throttle row per key.')

@api.model
def _client_ip(self):
headers = request.httprequest.headers
return (
headers.get('CF-Connecting-IP')
or headers.get('X-Forwarded-For', '').split(',')[0].strip()
or request.httprequest.remote_addr
or 'unknown'
candidates = (
headers.get('X-Odusite-Client-IP'),
headers.get('CF-Connecting-IP'),
headers.get('X-Forwarded-For', '').split(',')[0].strip(),
request.httprequest.remote_addr,
)
for candidate in candidates:
try:
return str(ipaddress.ip_address(candidate))
except (TypeError, ValueError):
continue
return 'unknown'

@api.model
def _enforce(self, scope='form', limit=None, window=None, key=None):
Expand All @@ -67,22 +74,35 @@ def _enforce(self, scope='form', limit=None, window=None, key=None):
return
full_key = '%s:%s' % (scope, key or self._client_ip())
now = int(time.time())
self.env.cr.execute(
"""
INSERT INTO odusite_rate_limit (key, window_start, hits)
VALUES (%(k)s, %(now)s, 1)
ON CONFLICT (key) DO UPDATE SET
hits = CASE
WHEN odusite_rate_limit.window_start > %(now)s - %(w)s
THEN odusite_rate_limit.hits + 1 ELSE 1 END,
window_start = CASE
WHEN odusite_rate_limit.window_start > %(now)s - %(w)s
THEN odusite_rate_limit.window_start ELSE %(now)s END
RETURNING hits
""",
{'k': full_key, 'now': now, 'w': window},
)
hits = self.env.cr.fetchone()[0]
# API errors roll back the request cursor. Commit the throttle update on
# an isolated cursor so rejected attempts still count toward the limit.
with self.env.registry.cursor() as cr:
cr.execute(
"""
INSERT INTO odusite_rate_limit
(key, window_start, expires_at, hits)
VALUES (%(k)s, %(now)s, %(expires)s, 1)
ON CONFLICT (key) DO UPDATE SET
hits = CASE
WHEN odusite_rate_limit.expires_at > %(now)s
THEN LEAST(odusite_rate_limit.hits + 1, %(cap)s)
ELSE 1 END,
window_start = CASE
WHEN odusite_rate_limit.expires_at > %(now)s
THEN odusite_rate_limit.window_start ELSE %(now)s END,
expires_at = CASE
WHEN odusite_rate_limit.expires_at > %(now)s
THEN odusite_rate_limit.expires_at ELSE %(expires)s END
RETURNING hits
""",
{
'k': full_key,
'now': now,
'expires': now + max(1, window),
'cap': limit + 1,
},
)
hits = cr.fetchone()[0]
if hits > limit:
raise ApiError(429, 'too_many_requests',
'Too many submissions, please try again later.')
Expand All @@ -91,6 +111,6 @@ def _enforce(self, scope='form', limit=None, window=None, key=None):
def _gc_rate_limit(self):
# Drop rows whose window ended over a day ago to keep the table small.
self.env.cr.execute(
"DELETE FROM odusite_rate_limit WHERE window_start < %s",
"DELETE FROM odusite_rate_limit WHERE expires_at < %s",
(int(time.time()) - 86400,),
)
1 change: 1 addition & 0 deletions addons/odusite_base/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from . import test_api_core
from . import test_jwt
from . import test_rate_limit
from . import test_search
from . import test_webhook_queue
8 changes: 8 additions & 0 deletions addons/odusite_base/tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ def assert_api_error(self, response, body, status, code):
self.assertTrue(body and 'error' in body, f'expected error body, got: {body}')
self.assertEqual(body['error']['code'], code)

def clear_rate_limit(self, key):
self.env['odusite.rate.limit'].sudo().search([('key', '=', key)]).unlink()

def rate_limit_hits(self, key):
row = self.env['odusite.rate.limit'].sudo().search(
[('key', '=', key)], limit=1)
return row.hits if row else None

# -- Auth helpers ----------------------------------------------------

@classmethod
Expand Down
56 changes: 56 additions & 0 deletions addons/odusite_base/tests/test_rate_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import time

from odoo.tests.common import TransactionCase, tagged

from ..controllers.api import ApiError


@tagged('post_install', '-at_install')
class TestRateLimit(TransactionCase):

def _delete_committed_key(self, key):
with self.env.registry.cursor() as cr:
cr.execute("DELETE FROM odusite_rate_limit WHERE key = %s", (key,))

def test_rejected_hit_commits_on_isolated_cursor(self):
limiter_key = 'test:rollback-persistence'
RateLimit = self.env['odusite.rate.limit'].sudo()
self.env['ir.config_parameter'].sudo().set_param(
'odusite.rate_limit_force_in_tests', '1')
self._delete_committed_key(limiter_key)
try:
RateLimit._enforce(
scope='test', key='rollback-persistence', limit=1, window=60)
with self.assertRaises(ApiError):
RateLimit._enforce(
scope='test', key='rollback-persistence', limit=1, window=60)

with self.env.registry.cursor() as cr:
cr.execute(
"SELECT hits FROM odusite_rate_limit WHERE key = %s",
(limiter_key,),
)
self.assertEqual(cr.fetchone(), (2,))
finally:
self._delete_committed_key(limiter_key)

def test_gc_keeps_active_long_window(self):
now = int(time.time())
RateLimit = self.env['odusite.rate.limit'].sudo()
active = RateLimit.create({
'key': 'test:active-long-window',
'window_start': now - 2 * 86400,
'expires_at': now + 86400,
'hits': 3,
})
expired = RateLimit.create({
'key': 'test:expired-window',
'window_start': now - 3 * 86400,
'expires_at': now - 2 * 86400,
'hits': 2,
})

RateLimit._gc_rate_limit()

self.assertTrue(active.exists(), 'an active long window must survive GC')
self.assertFalse(expired.exists(), 'an old expired window should be deleted')
14 changes: 10 additions & 4 deletions addons/odusite_crm/tests/test_contact_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,20 +83,26 @@ def test_contact_utm(self):

def test_contact_rate_limit(self):
icp = self.env['ir.config_parameter'].sudo()
client_ip = '198.51.100.20'
limiter_key = f'contact:{client_ip}'
headers = {'X-Odusite-Client-IP': client_ip}
# Enforcement is skipped under the test runner by default (counters
# accumulate across unrelated cases); this test opts in explicitly.
icp.set_param('odusite.rate_limit_force_in_tests', '1')
icp.set_param('odusite.form_rate_limit', '1')
self.env['odusite.rate.limit'].sudo().search([]).unlink()
self.clear_rate_limit(limiter_key)
try:
response, body = self.api('POST', '/forms/contact', VALID_PAYLOAD)
response, body = self.api(
'POST', '/forms/contact', VALID_PAYLOAD, headers=headers)
self.assertEqual(response.status_code, 200, body)
response, body = self.api('POST', '/forms/contact', VALID_PAYLOAD)
self.assertEqual(self.rate_limit_hits(limiter_key), 1)
response, body = self.api(
'POST', '/forms/contact', VALID_PAYLOAD, headers=headers)
self.assert_api_error(response, body, 429, 'too_many_requests')
finally:
icp.set_param('odusite.rate_limit_force_in_tests', False)
icp.set_param('odusite.form_rate_limit', False)
self.env['odusite.rate.limit'].sudo().search([]).unlink()
self.clear_rate_limit(limiter_key)

def test_generic_form_unknown_model(self):
# res.users is never in the odusite.api form whitelist. (The full
Expand Down
6 changes: 5 additions & 1 deletion addons/odusite_portal/tests/test_signup_confirm.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ def setUpClass(cls):
cls.ICP = cls.env['ir.config_parameter'].sudo()

def _enable_b2c(self):
self.ICP.set_param('auth_signup.invitation_scope', 'b2c')
# Keep flow tests independent of the process-wide config cache. The
# settings-to-parameter integration is covered by its dedicated test.
self.patch(
self.registry['res.users'], '_get_signup_invitation_scope',
lambda _users: 'b2c')

def _find_user(self, login):
Users = self.env['res.users'].sudo().with_context(active_test=False)
Expand Down
5 changes: 3 additions & 2 deletions site/src/blocks/events/pages/event.astro
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ const Tickets = canRegister ? 'form' : 'div';
<>
<div class="attendees" id="attendees"></div>
{turnstileSiteKey && (
<div class="cf-turnstile event-register__turnstile" data-sitekey={turnstileSiteKey}></div>
<div id="event-register-turnstile" class="cf-turnstile event-register__turnstile" data-sitekey={turnstileSiteKey}></div>
)}
<p class="register-msg" id="register-msg" role="alert" hidden></p>
<Button type="submit" class="register-submit">Register</Button>
Expand Down Expand Up @@ -319,7 +319,8 @@ const Tickets = canRegister ? 'form' : 'div';
} finally {
if (submit) submit.disabled = false;
// A consumed Turnstile token cannot be reused — reset for a retry.
(window as unknown as { turnstile?: { reset?: () => void } }).turnstile?.reset?.();
(window as unknown as { turnstile?: { reset?: (widget?: string) => void } })
.turnstile?.reset?.('#event-register-turnstile');
}
});
}
Expand Down
6 changes: 3 additions & 3 deletions site/src/blocks/forms/pages/contact.astro
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const turnstileSiteKey = getEnv(Astro).PUBLIC_TURNSTILE_SITE_KEY;
</div>

{turnstileSiteKey && (
<div class="cf-turnstile contact-turnstile" data-sitekey={turnstileSiteKey}></div>
<div id="contact-turnstile" class="cf-turnstile contact-turnstile" data-sitekey={turnstileSiteKey}></div>
)}

<p id="contact-error" class="contact-error" role="alert" hidden></p>
Expand Down Expand Up @@ -140,8 +140,8 @@ const turnstileSiteKey = getEnv(Astro).PUBLIC_TURNSTILE_SITE_KEY;
errorBox.hidden = false;
}
// A consumed Turnstile token cannot be reused — reset for a retry.
const turnstile = (window as unknown as { turnstile?: { reset?: () => void } }).turnstile;
turnstile?.reset?.();
const turnstile = (window as unknown as { turnstile?: { reset?: (widget?: string) => void } }).turnstile;
turnstile?.reset?.('#contact-turnstile');
};

form.addEventListener('submit', async (event) => {
Expand Down
2 changes: 1 addition & 1 deletion site/src/blocks/jobs/api/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export const POST: APIRoute = async (context) => {

const applyHeaders: Record<string, string> = { 'X-Odusite-Token': env.ODUSITE_TOKEN };
const clientIp = context.request.headers.get('CF-Connecting-IP');
if (clientIp) applyHeaders['CF-Connecting-IP'] = clientIp;
if (clientIp) applyHeaders['X-Odusite-Client-IP'] = clientIp;

const response = await fetch(url, {
method: 'POST',
Expand Down
6 changes: 3 additions & 3 deletions site/src/blocks/jobs/pages/job.astro
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ Astro.response.headers.set(TAGS_HEADER, `jobs,jobs:${job.id}`);
</div>

{turnstileSiteKey && (
<div class="cf-turnstile job-apply__turnstile" data-sitekey={turnstileSiteKey}></div>
<div id="job-apply-turnstile" class="cf-turnstile job-apply__turnstile" data-sitekey={turnstileSiteKey}></div>
)}

<p id="apply-error" class="job-apply__error" role="alert" hidden></p>
Expand Down Expand Up @@ -128,8 +128,8 @@ Astro.response.headers.set(TAGS_HEADER, `jobs,jobs:${job.id}`);
errorBox.hidden = false;
}
// A consumed Turnstile token cannot be reused — reset for a retry.
const turnstile = (window as unknown as { turnstile?: { reset?: () => void } }).turnstile;
turnstile?.reset?.();
const turnstile = (window as unknown as { turnstile?: { reset?: (widget?: string) => void } }).turnstile;
turnstile?.reset?.('#job-apply-turnstile');
};

form.addEventListener('submit', async (event) => {
Expand Down
5 changes: 3 additions & 2 deletions site/src/blocks/newsletter/components/NewsletterForm.astro
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const turnstileSiteKey = getEnv(Astro).PUBLIC_TURNSTILE_SITE_KEY;
/>
</div>
{turnstileSiteKey && (
<div class="cf-turnstile od-newsletter__turnstile" data-sitekey={turnstileSiteKey}></div>
<div id="newsletter-turnstile" class="cf-turnstile od-newsletter__turnstile" data-sitekey={turnstileSiteKey}></div>
)}
<p class="od-newsletter__msg" role="status" hidden></p>
</form>
Expand Down Expand Up @@ -85,7 +85,8 @@ const turnstileSiteKey = getEnv(Astro).PUBLIC_TURNSTILE_SITE_KEY;
} finally {
if (button) button.disabled = false;
// A consumed Turnstile token cannot be reused — reset for a retry.
(window as unknown as { turnstile?: { reset?: () => void } }).turnstile?.reset?.();
(window as unknown as { turnstile?: { reset?: (widget?: string) => void } })
.turnstile?.reset?.('#newsletter-turnstile');
}
});
}
Expand Down
5 changes: 3 additions & 2 deletions site/src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,10 @@ export async function apiFetch<T = unknown>(
...odooAccessHeaders(env),
};
// Forward the real client IP so Odoo's per-IP throttle keys on the visitor,
// not on this Worker. Trusted because the whole API is gated by the token.
// not on this Worker. A custom header survives cross-zone Worker requests;
// Cloudflare replaces CF-Connecting-IP in that case.
const clientIp = (ctx as { request?: Request }).request?.headers.get('CF-Connecting-IP');
if (clientIp) headers['CF-Connecting-IP'] = clientIp;
if (clientIp) headers['X-Odusite-Client-IP'] = clientIp;
if (options.auth !== false) {
const token = getAccessToken(ctx);
if (token) headers['Authorization'] = `Bearer ${token}`;
Expand Down
Loading