Skip to content

Commit d45b62e

Browse files
committed
store waf headers (#24087)
* store waf headers * set_request_metadata to consistently use `None` to clear * rename select_request_fingerprint_headers to select_request_metadata * lint fix
1 parent 27091a2 commit d45b62e

File tree

20 files changed

+332
-70
lines changed

20 files changed

+332
-70
lines changed

src/olympia/access/middleware.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,15 @@ def process_request(self, request):
1717
# lazy to avoid early database queries.
1818
core.set_user(request.user)
1919
core.set_remote_addr(request.META.get('REMOTE_ADDR'))
20+
core.set_request_metadata(core.select_request_metadata(request.headers))
2021

2122
def process_response(self, request, response):
2223
core.set_user(None)
2324
core.set_remote_addr(None)
25+
core.set_request_metadata(None)
2426
return response
2527

2628
def process_exception(self, request, exception):
2729
core.set_user(None)
2830
core.set_remote_addr(None)
31+
core.set_request_metadata(None)

src/olympia/access/tests.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from django.contrib.auth.models import AnonymousUser
2+
from django.http import HttpResponse
23

34
import pytest
45

5-
from olympia import amo
6+
from olympia import amo, core
67
from olympia.access.models import Group, GroupUser
78
from olympia.addons.models import Addon, AddonUser
8-
from olympia.amo.tests import TestCase, addon_factory, user_factory
9+
from olympia.amo.tests import RequestFactory, TestCase, addon_factory, user_factory
910
from olympia.users.models import UserProfile
1011

1112
from .acl import (
@@ -20,6 +21,7 @@
2021
match_rules,
2122
reserved_guid_addon_submission_allowed,
2223
)
24+
from .middleware import UserAndAddrMiddleware
2325

2426

2527
pytestmark = pytest.mark.django_db
@@ -321,3 +323,39 @@ def test_reserved_guid_addon_submission_allowed_not_mozilla_not_allowed(guid):
321323
user = user_factory()
322324
data = {'guid': guid}
323325
assert not reserved_guid_addon_submission_allowed(user, data)
326+
327+
328+
def test_user_and_addr_middleware():
329+
middleware = UserAndAddrMiddleware(lambda x: response)
330+
wanted_headers = {
331+
'Client-JA4': 'd1234-5678-0000',
332+
'X-SigSci-Tags': 'TAG,ANOTHERTAG',
333+
}
334+
request = RequestFactory(
335+
REMOTE_ADDR='123.45.67.89',
336+
headers={**wanted_headers, 'another_HEADER': 'Ignored'},
337+
).get('/')
338+
assert request.headers
339+
response = HttpResponse()
340+
user = user_factory()
341+
342+
request.user = user
343+
344+
assert core.get_user() is None
345+
assert core.get_remote_addr() is None
346+
assert core.get_request_metadata() == {}
347+
348+
middleware.process_request(request)
349+
assert core.get_user() == user
350+
assert core.get_remote_addr() == '123.45.67.89'
351+
assert core.get_request_metadata() == wanted_headers, request.headers
352+
353+
middleware.process_response(request, response)
354+
assert core.get_user() is None
355+
assert core.get_remote_addr() is None
356+
assert core.get_request_metadata() == {}
357+
358+
middleware.process_exception(request, Exception())
359+
assert core.get_user() is None
360+
assert core.get_remote_addr() is None
361+
assert core.get_request_metadata() == {}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Generated by Django 4.2.25 on 2025-10-29 16:38
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
import django.utils.timezone
6+
import olympia.amo.models
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
('activity', '0029_alter_activitylog_action'),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name='RequestFingerprintLog',
18+
fields=[
19+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20+
('created', models.DateTimeField(blank=True, default=django.utils.timezone.now, editable=False)),
21+
('modified', models.DateTimeField(auto_now=True)),
22+
('ja4', models.CharField(db_index=True, max_length=36)),
23+
('signals', models.JSONField(default=list)),
24+
('activity_log', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='activity.activitylog')),
25+
],
26+
options={
27+
'db_table': 'log_activity_request_fingerprint',
28+
'ordering': ('-created',),
29+
},
30+
bases=(olympia.amo.models.SaveUpdateMixin, models.Model),
31+
),
32+
]

src/olympia/activity/models.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,23 @@ def save(self, *args, **kwargs):
284284
return super().save(*args, **kwargs)
285285

286286

287+
class RequestFingerprintLog(ModelBase):
288+
"""
289+
This table is for indexing the activity log by some request fingerprints, (only for
290+
specific actions).
291+
"""
292+
293+
activity_log = models.OneToOneField('ActivityLog', on_delete=models.CASCADE)
294+
# https://github.com/FoxIO-LLC/ja4/blob/main/technical_details/JA4.md
295+
# e.g. t13d1516h2_8daaf6152771_b186095e22b6
296+
ja4 = models.CharField(max_length=36, db_index=True)
297+
signals = models.JSONField(default=list)
298+
299+
class Meta:
300+
db_table = 'log_activity_request_fingerprint'
301+
ordering = ('-created',)
302+
303+
287304
class RatingLog(ModelBase):
288305
"""
289306
This table is for indexing the activity log by Ratings (user reviews).
@@ -515,6 +532,21 @@ def create(self, *args, **kw):
515532
created=kw.get('created', timezone.now()),
516533
)
517534

535+
# Also, if we're storing the ip address, store the request fingerprints too
536+
ja4 = core.get_request_metadata().get('Client-JA4') or ''
537+
if ja4:
538+
ja4 = ja4[: RequestFingerprintLog._meta.get_field('ja4').max_length]
539+
# it should be a comma-seperated string
540+
signals = (core.get_request_metadata().get('X-SigSci-Tags') or '').split(
541+
','
542+
)
543+
RequestFingerprintLog.objects.create(
544+
ja4=ja4,
545+
signals=signals,
546+
activity_log=al,
547+
created=kw.get('created', timezone.now()),
548+
)
549+
518550
return al
519551

520552

src/olympia/activity/tests/test_admin.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,16 +53,16 @@ def test_search_for_single_ip(self):
5353
user2 = user_factory()
5454
user3 = user_factory()
5555
addon = addon_factory(users=[user3])
56-
with core.override_remote_addr('127.0.0.2'):
56+
with core.override_remote_addr_or_metadata(ip_address='127.0.0.2'):
5757
user2.update(email='[email protected]')
5858
# That will make user2 match our query.
5959
ActivityLog.objects.create(amo.LOG.LOG_IN, user=user2)
60-
with core.override_remote_addr('127.0.0.2'):
60+
with core.override_remote_addr_or_metadata(ip_address='127.0.0.2'):
6161
# That will make user3 match our query.
6262
ActivityLog.objects.create(
6363
amo.LOG.ADD_VERSION, addon.current_version, addon, user=user3
6464
)
65-
with core.override_remote_addr('127.0.0.1'):
65+
with core.override_remote_addr_or_metadata(ip_address='127.0.0.1'):
6666
extra_user = user_factory() # Extra user that shouldn't match
6767
ActivityLog.objects.create(amo.LOG.LOG_IN, user=extra_user)
6868
with self.assertNumQueries(11):

src/olympia/activity/tests/test_models.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
DraftComment,
1818
GenericMozillaUser,
1919
IPLog,
20+
RequestFingerprintLog,
2021
ReviewActionReasonLog,
2122
attachment_upload_path,
2223
)
@@ -374,7 +375,7 @@ def test_output(self):
374375
def test_to_string_num_queries_model_depending_on_addon(self):
375376
addon = Addon.objects.get()
376377
addon2 = addon_factory()
377-
with core.override_remote_addr('1.1.1.1'):
378+
with core.override_remote_addr_or_metadata(ip_address='1.1.1.1'):
378379
ActivityLog.objects.create(
379380
amo.LOG.ADD_VERSION,
380381
addon,
@@ -410,7 +411,7 @@ def test_ip_log(self):
410411
# create an IPLog.
411412
action = amo.LOG.REJECT_VERSION
412413
assert not getattr(action, 'store_ip', False)
413-
with core.override_remote_addr('127.0.4.8'):
414+
with core.override_remote_addr_or_metadata(ip_address='127.0.4.8'):
414415
activity = ActivityLog.objects.create(
415416
action,
416417
addon,
@@ -422,7 +423,7 @@ def test_ip_log(self):
422423
# create an IPLog.
423424
action = amo.LOG.ADD_VERSION
424425
assert getattr(action, 'store_ip', False)
425-
with core.override_remote_addr('15.16.23.42'):
426+
with core.override_remote_addr_or_metadata(ip_address='15.16.23.42'):
426427
activity = ActivityLog.objects.create(
427428
action,
428429
addon,
@@ -435,6 +436,48 @@ def test_ip_log(self):
435436
assert ip_log._ip_address == '15.16.23.42'
436437
assert ip_log.ip_address_binary == IPv4Address('15.16.23.42')
437438

439+
def test_request_fingerprint_log(self):
440+
addon = Addon.objects.get()
441+
assert RequestFingerprintLog.objects.count() == 0
442+
# 37 charactors to test truncation to 36 characters.
443+
metadata = {
444+
'Client-JA4': 'a' * 37,
445+
'X-SigSci-Tags': 'TAG1,TAG2',
446+
'other': 'data',
447+
}
448+
# Creating an activity log for an action without store_ip=True doesn't
449+
# create an RequestFingerprintLog.
450+
action = amo.LOG.REJECT_VERSION
451+
assert not getattr(action, 'store_ip', False)
452+
with core.override_remote_addr_or_metadata(
453+
ip_address='127.0.4.8', metadata=metadata
454+
):
455+
activity = ActivityLog.objects.create(
456+
action,
457+
addon,
458+
addon.current_version,
459+
user=self.request.user,
460+
)
461+
assert RequestFingerprintLog.objects.count() == 0
462+
# Creating an activity log for an action *with* store_ip=True *does*
463+
# create an RequestFingerprintLog.
464+
action = amo.LOG.ADD_VERSION
465+
assert getattr(action, 'store_ip', False)
466+
with core.override_remote_addr_or_metadata(
467+
ip_address='15.16.23.42', metadata=metadata
468+
):
469+
activity = ActivityLog.objects.create(
470+
action,
471+
addon,
472+
addon.current_version,
473+
user=self.request.user,
474+
)
475+
assert RequestFingerprintLog.objects.count() == 1
476+
fingerprint_log = RequestFingerprintLog.objects.get()
477+
assert fingerprint_log.activity_log == activity
478+
assert fingerprint_log.ja4 == 'a' * 36 # Truncated to 36 characters.
479+
assert fingerprint_log.signals == ['TAG1', 'TAG2']
480+
438481
def test_review_action_reason_log(self):
439482
addon = Addon.objects.get()
440483
assert ReviewActionReasonLog.objects.count() == 0

src/olympia/addons/tests/test_admin.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -267,25 +267,25 @@ def test_search_by_ip(self):
267267
self.client.force_login(user)
268268

269269
addon = addon_factory(guid='@foo')
270-
with core.override_remote_addr('4.8.15.16'):
270+
with core.override_remote_addr_or_metadata(ip_address='4.8.15.16'):
271271
ActivityLog.objects.create(
272272
amo.LOG.ADD_VERSION, addon.current_version, addon, user=user
273273
)
274274
version_factory(addon=addon)
275-
with core.override_remote_addr('4.8.15.16'):
275+
with core.override_remote_addr_or_metadata(ip_address='4.8.15.16'):
276276
ActivityLog.objects.create(
277277
amo.LOG.ADD_VERSION, addon.current_version, addon, user=user
278278
)
279279
second_addon = addon_factory(guid='@bar')
280-
with core.override_remote_addr('4.8.15.16'):
280+
with core.override_remote_addr_or_metadata(ip_address='4.8.15.16'):
281281
ActivityLog.objects.create(
282282
amo.LOG.ADD_VERSION,
283283
second_addon.current_version,
284284
second_addon,
285285
user=user,
286286
)
287287
third_addon = addon_factory(guid='@xyz')
288-
with core.override_remote_addr('127.0.0.1'):
288+
with core.override_remote_addr_or_metadata(ip_address='127.0.0.1'):
289289
ActivityLog.objects.create(
290290
amo.LOG.ADD_VERSION,
291291
third_addon.current_version,

src/olympia/core/__init__.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import contextlib
22
import threading
3+
from collections.abc import Mapping
34

45

56
_locals = threading.local()
67
_locals.user = None
78
_locals.remote_addr = None
9+
_locals.request_metadata = None
810

911

1012
def get_user():
@@ -28,10 +30,40 @@ def set_remote_addr(remote_addr):
2830
_locals.remote_addr = remote_addr
2931

3032

33+
def get_request_metadata():
34+
return getattr(_locals, 'request_metadata', None) or {}
35+
36+
37+
def set_request_metadata(data):
38+
if data and isinstance(data, Mapping):
39+
_locals.request_metadata = {key: value for key, value in data.items() if value}
40+
else:
41+
_locals.request_metadata = None
42+
43+
44+
def select_request_metadata(headers):
45+
"""Get the two headers from from request.headers that we currently care about,
46+
if present.
47+
Note this function also normalizes the header names if it's called with
48+
request.headers (HttpHeaders is case-insensitive)."""
49+
return {
50+
key: val
51+
for key in ('Client-JA4', 'X-SigSci-Tags')
52+
if (val := headers.get(key)) is not None
53+
}
54+
55+
3156
@contextlib.contextmanager
32-
def override_remote_addr(remote_addr_override):
57+
def override_remote_addr_or_metadata(*, ip_address=None, metadata=None):
3358
"""Override value returned by get_remote_addr() for a specific context."""
34-
original = get_remote_addr()
35-
set_remote_addr(remote_addr_override)
59+
original_ip = get_remote_addr()
60+
original_metadata = get_request_metadata()
61+
if ip_address is not None:
62+
set_remote_addr(ip_address)
63+
if metadata is not None:
64+
set_request_metadata(metadata)
3665
yield
37-
set_remote_addr(original)
66+
if ip_address is not None:
67+
set_remote_addr(original_ip)
68+
if metadata is not None:
69+
set_request_metadata(original_metadata)

src/olympia/core/tests/test_init.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
from django.contrib.auth.models import AnonymousUser
2+
from django.http.request import HttpHeaders
23

34
from olympia import core
45
from olympia.users.models import UserProfile
56

67

7-
def test_override_remote_addr():
8+
def test_override_remote_addr_or_metadata():
89
original = core.get_remote_addr()
910

10-
with core.override_remote_addr('some other value'):
11+
with core.override_remote_addr_or_metadata(ip_address='some other value'):
1112
assert core.get_remote_addr() == 'some other value'
1213

1314
assert core.get_remote_addr() == original
@@ -23,3 +24,21 @@ def test_set_get_user_anonymous():
2324

2425
core.set_user(None)
2526
assert core.get_user() is None
27+
28+
29+
def test_get_request_metadata_and_set_request_metadata():
30+
assert core.get_request_metadata() == {}
31+
32+
core.set_request_metadata(data=None)
33+
assert core.get_request_metadata() == {}
34+
35+
core.set_request_metadata({'a': 'b', 'c': None})
36+
assert core.get_request_metadata() == {'a': 'b'}
37+
38+
39+
def test_select_request_metadata():
40+
assert core.select_request_metadata(HttpHeaders({})) == {}
41+
42+
assert core.select_request_metadata(
43+
HttpHeaders({'HTTP_Client-JA4': None, 'HTTP_X_SigSci-TAGS': 'SOME,tags'})
44+
) == {'X-SigSci-Tags': 'SOME,tags'}

src/olympia/devhub/tests/test_feeds.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def log_updates(self, num, version_string='1'):
3838
version = Version.objects.create(version=version_string, addon=self.addon)
3939

4040
for _i in range(num):
41-
with core.override_remote_addr('127.0.0.1'):
41+
with core.override_remote_addr_or_metadata(ip_address='127.0.0.1'):
4242
ActivityLog.objects.create(amo.LOG.ADD_VERSION, self.addon, version)
4343

4444
def log_status(self, num):

0 commit comments

Comments
 (0)