Skip to content

Commit a908ffd

Browse files
authored
Prevent duplicate POST keys in requests (#16732)
* Add form IDs and update functional test * Reformat comment * Remove unnecessary cast to MultiDict * Add a failing test * Add view deriver to prohibit duplicate POST keys * Permit duplicate POST keys for file_upload view * Update translations
1 parent 001165f commit a908ffd

File tree

11 files changed

+144
-79
lines changed

11 files changed

+144
-79
lines changed

tests/functional/manage/test_views.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,15 @@ def test_changing_password_succeeds(self, webtest, socket_enabled):
6464
login_page = webtest.get("/account/login/", status=HTTPStatus.OK)
6565

6666
# Fill & submit the login form
67-
login_form = login_page.forms[2] # TODO: form should have an ID, doesn't yet
67+
login_form = login_page.forms["login-form"]
6868
anonymous_csrf_token = login_form["csrf_token"].value
6969
login_form["username"] = user.username
7070
login_form["password"] = "password"
7171
login_form["csrf_token"] = anonymous_csrf_token
7272

7373
two_factor_page = login_form.submit().follow(status=HTTPStatus.OK)
7474

75-
# TODO: form doesn't have an ID yet
76-
two_factor_form = two_factor_page.forms[2]
75+
two_factor_form = two_factor_page.forms["totp-auth-form"]
7776
two_factor_form["csrf_token"] = anonymous_csrf_token
7877

7978
# Generate the correct TOTP value from the known secret
@@ -94,9 +93,8 @@ def test_changing_password_succeeds(self, webtest, socket_enabled):
9493
assert anonymous_csrf_token != logged_in_csrf_token
9594

9695
# Fill & submit the change password form
97-
# TODO: form doesn't have an ID yet
9896
new_password = faker.Faker().password() # a secure-enough password for testing
99-
change_password_form = change_password_page.forms[3]
97+
change_password_form = change_password_page.forms["change-password-form"]
10098
change_password_form["csrf_token"] = logged_in_csrf_token
10199
change_password_form["password"] = "password"
102100
change_password_form["new_password"] = new_password

tests/functional/test_config.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
13+
from http import HTTPStatus
14+
15+
from webob.multidict import MultiDict
16+
17+
from ..common.db.accounts import UserFactory
18+
19+
20+
def test_rejects_duplicate_post_keys(webtest, socket_enabled):
21+
# create a User
22+
user = UserFactory.create(with_verified_primary_email=True, clear_pwd="password")
23+
24+
# /login is an unauthenticated endpoint that accepts a POST
25+
login_page = webtest.get("/account/login/", status=HTTPStatus.OK)
26+
27+
# Get the CSRF token from the form
28+
login_form = login_page.forms["login-form"]
29+
anonymous_csrf_token = login_form["csrf_token"].value
30+
31+
body = MultiDict()
32+
body.add("username", user.username)
33+
body.add("password", "password")
34+
body.add("csrf_token", anonymous_csrf_token)
35+
# Add multiple duplicate keys to the POST body, doesn't matter what they are
36+
body.add("foo", "bar")
37+
body.add("foo", "baz")
38+
39+
webtest.post("/account/login", params=body, status=HTTPStatus.BAD_REQUEST)

tests/unit/test_config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ def __init__(self):
290290
whitenoise_add_manifest=pretend.call_recorder(lambda *a, **kw: None),
291291
scan=pretend.call_recorder(lambda categories, ignore: None),
292292
commit=pretend.call_recorder(lambda: None),
293+
add_view_deriver=pretend.call_recorder(lambda *a, **kw: None),
293294
)
294295
configurator_cls = pretend.call_recorder(lambda settings: configurator_obj)
295296
monkeypatch.setattr(config, "Configurator", configurator_cls)
@@ -531,6 +532,11 @@ def __init__(self):
531532
pretend.call("json", json_renderer_obj),
532533
pretend.call("xmlrpc", xmlrpc_renderer_obj),
533534
]
535+
assert configurator_obj.add_view_deriver.calls == [
536+
pretend.call(
537+
config.reject_duplicate_post_keys_view, under=config.viewderivers.INGRESS
538+
)
539+
]
534540

535541
assert json_renderer_cls.calls == [
536542
pretend.call(

warehouse/config.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import base64
1414
import enum
15+
import functools
1516
import json
1617
import os
1718
import secrets
@@ -24,10 +25,11 @@
2425
import platformdirs
2526
import transaction
2627

27-
from pyramid import renderers
28+
from pyramid import renderers, viewderivers
2829
from pyramid.authorization import Allow, Authenticated
2930
from pyramid.config import Configurator as _Configurator
3031
from pyramid.exceptions import HTTPForbidden
32+
from pyramid.httpexceptions import HTTPBadRequest
3133
from pyramid.settings import asbool
3234
from pyramid.tweens import EXCVIEW
3335
from pyramid_rpc.xmlrpc import XMLRPCRenderer
@@ -266,6 +268,29 @@ def from_base64_encoded_json(configuration):
266268
return json.loads(base64.urlsafe_b64decode(configuration.encode("ascii")))
267269

268270

271+
def reject_duplicate_post_keys_view(view, info):
272+
if not info.options.get("permit_duplicate_post_keys"):
273+
274+
@functools.wraps(view)
275+
def wrapped(context, request):
276+
if request.POST:
277+
# Attempt to cast to a dict to catch duplicate keys
278+
try:
279+
dict(**request.POST)
280+
except TypeError:
281+
return HTTPBadRequest("POST body may not contain duplicate keys")
282+
283+
# Casting succeeded, so just return the regular view
284+
return view(context, request)
285+
286+
return wrapped
287+
288+
return view
289+
290+
291+
reject_duplicate_post_keys_view.options = {"permit_duplicate_post_keys"} # type: ignore
292+
293+
269294
def configure(settings=None):
270295
# Sanity check: regardless of what we're configuring, some of Warehouse's
271296
# application state depends on a handful of XDG directories existing.
@@ -817,6 +842,9 @@ def configure(settings=None):
817842
],
818843
)
819844

845+
# Reject requests with duplicate POST keys
846+
config.add_view_deriver(reject_duplicate_post_keys_view, under=viewderivers.INGRESS)
847+
820848
# Enable Warehouse to serve our static files
821849
prevent_http_cache = config.get_settings().get("pyramid.prevent_http_cache", False)
822850
config.add_static_view(
@@ -893,8 +921,8 @@ def configure(settings=None):
893921
)
894922

895923
# Sanity check our request and responses.
896-
# Note: It is very important that this go last. We need everything else that might
897-
# have added a tween to be registered prior to this.
924+
# Note: It is very important that this go last. We need everything else
925+
# that might have added a tween to be registered prior to this.
898926
config.include(".sanity")
899927

900928
# Finally, commit all of our changes

warehouse/forklift/legacy.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,7 @@ def _sort_releases(request: Request, project: Project):
487487
require_csrf=False,
488488
require_methods=["POST"],
489489
has_translations=True,
490+
permit_duplicate_post_keys=True,
490491
)
491492
def file_upload(request):
492493
# This is a list of warnings that we'll emit *IF* the request is successful.

warehouse/locale/messages.pot

Lines changed: 42 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ msgstr ""
151151
msgid "Successful WebAuthn assertion"
152152
msgstr ""
153153

154-
#: warehouse/accounts/views.py:569 warehouse/manage/views/__init__.py:870
154+
#: warehouse/accounts/views.py:569 warehouse/manage/views/__init__.py:865
155155
msgid "Recovery code accepted. The supplied code cannot be used again."
156156
msgstr ""
157157

@@ -285,7 +285,7 @@ msgid "You are now ${role} of the '${project_name}' project."
285285
msgstr ""
286286

287287
#: warehouse/accounts/views.py:1548 warehouse/accounts/views.py:1791
288-
#: warehouse/manage/views/__init__.py:1249
288+
#: warehouse/manage/views/__init__.py:1242
289289
msgid ""
290290
"Trusted publishing is temporarily disabled. See https://pypi.org/help"
291291
"#admin-intervention for details."
@@ -305,19 +305,19 @@ msgstr ""
305305
msgid "You can't register more than 3 pending trusted publishers at once."
306306
msgstr ""
307307

308-
#: warehouse/accounts/views.py:1614 warehouse/manage/views/__init__.py:1304
309-
#: warehouse/manage/views/__init__.py:1417
310-
#: warehouse/manage/views/__init__.py:1529
311-
#: warehouse/manage/views/__init__.py:1639
308+
#: warehouse/accounts/views.py:1614 warehouse/manage/views/__init__.py:1297
309+
#: warehouse/manage/views/__init__.py:1410
310+
#: warehouse/manage/views/__init__.py:1522
311+
#: warehouse/manage/views/__init__.py:1632
312312
msgid ""
313313
"There have been too many attempted trusted publisher registrations. Try "
314314
"again later."
315315
msgstr ""
316316

317-
#: warehouse/accounts/views.py:1625 warehouse/manage/views/__init__.py:1318
318-
#: warehouse/manage/views/__init__.py:1431
319-
#: warehouse/manage/views/__init__.py:1543
320-
#: warehouse/manage/views/__init__.py:1653
317+
#: warehouse/accounts/views.py:1625 warehouse/manage/views/__init__.py:1311
318+
#: warehouse/manage/views/__init__.py:1424
319+
#: warehouse/manage/views/__init__.py:1536
320+
#: warehouse/manage/views/__init__.py:1646
321321
msgid "The trusted publisher could not be registered"
322322
msgstr ""
323323

@@ -427,130 +427,130 @@ msgstr ""
427427
msgid "This team name has already been used. Choose a different team name."
428428
msgstr ""
429429

430-
#: warehouse/manage/views/__init__.py:282
430+
#: warehouse/manage/views/__init__.py:281
431431
msgid "Account details updated"
432432
msgstr ""
433433

434-
#: warehouse/manage/views/__init__.py:312
434+
#: warehouse/manage/views/__init__.py:311
435435
msgid "Email ${email_address} added - check your email for a verification link"
436436
msgstr ""
437437

438-
#: warehouse/manage/views/__init__.py:818
438+
#: warehouse/manage/views/__init__.py:813
439439
msgid "Recovery codes already generated"
440440
msgstr ""
441441

442-
#: warehouse/manage/views/__init__.py:819
442+
#: warehouse/manage/views/__init__.py:814
443443
msgid "Generating new recovery codes will invalidate your existing codes."
444444
msgstr ""
445445

446-
#: warehouse/manage/views/__init__.py:928
446+
#: warehouse/manage/views/__init__.py:923
447447
msgid "Verify your email to create an API token."
448448
msgstr ""
449449

450-
#: warehouse/manage/views/__init__.py:1028
450+
#: warehouse/manage/views/__init__.py:1021
451451
msgid "API Token does not exist."
452452
msgstr ""
453453

454-
#: warehouse/manage/views/__init__.py:1060
454+
#: warehouse/manage/views/__init__.py:1053
455455
msgid "Invalid credentials. Try again"
456456
msgstr ""
457457

458-
#: warehouse/manage/views/__init__.py:1285
458+
#: warehouse/manage/views/__init__.py:1278
459459
msgid ""
460460
"GitHub-based trusted publishing is temporarily disabled. See "
461461
"https://pypi.org/help#admin-intervention for details."
462462
msgstr ""
463463

464-
#: warehouse/manage/views/__init__.py:1398
464+
#: warehouse/manage/views/__init__.py:1391
465465
msgid ""
466466
"GitLab-based trusted publishing is temporarily disabled. See "
467467
"https://pypi.org/help#admin-intervention for details."
468468
msgstr ""
469469

470-
#: warehouse/manage/views/__init__.py:1510
470+
#: warehouse/manage/views/__init__.py:1503
471471
msgid ""
472472
"Google-based trusted publishing is temporarily disabled. See "
473473
"https://pypi.org/help#admin-intervention for details."
474474
msgstr ""
475475

476-
#: warehouse/manage/views/__init__.py:1619
476+
#: warehouse/manage/views/__init__.py:1612
477477
msgid ""
478478
"ActiveState-based trusted publishing is temporarily disabled. See "
479479
"https://pypi.org/help#admin-intervention for details."
480480
msgstr ""
481481

482-
#: warehouse/manage/views/__init__.py:1854
483-
#: warehouse/manage/views/__init__.py:2155
484-
#: warehouse/manage/views/__init__.py:2263
482+
#: warehouse/manage/views/__init__.py:1847
483+
#: warehouse/manage/views/__init__.py:2148
484+
#: warehouse/manage/views/__init__.py:2256
485485
msgid ""
486486
"Project deletion temporarily disabled. See https://pypi.org/help#admin-"
487487
"intervention for details."
488488
msgstr ""
489489

490-
#: warehouse/manage/views/__init__.py:1986
491-
#: warehouse/manage/views/__init__.py:2071
492-
#: warehouse/manage/views/__init__.py:2172
493-
#: warehouse/manage/views/__init__.py:2272
490+
#: warehouse/manage/views/__init__.py:1979
491+
#: warehouse/manage/views/__init__.py:2064
492+
#: warehouse/manage/views/__init__.py:2165
493+
#: warehouse/manage/views/__init__.py:2265
494494
msgid "Confirm the request"
495495
msgstr ""
496496

497-
#: warehouse/manage/views/__init__.py:1998
497+
#: warehouse/manage/views/__init__.py:1991
498498
msgid "Could not yank release - "
499499
msgstr ""
500500

501-
#: warehouse/manage/views/__init__.py:2083
501+
#: warehouse/manage/views/__init__.py:2076
502502
msgid "Could not un-yank release - "
503503
msgstr ""
504504

505-
#: warehouse/manage/views/__init__.py:2184
505+
#: warehouse/manage/views/__init__.py:2177
506506
msgid "Could not delete release - "
507507
msgstr ""
508508

509-
#: warehouse/manage/views/__init__.py:2284
509+
#: warehouse/manage/views/__init__.py:2277
510510
msgid "Could not find file"
511511
msgstr ""
512512

513-
#: warehouse/manage/views/__init__.py:2288
513+
#: warehouse/manage/views/__init__.py:2281
514514
msgid "Could not delete file - "
515515
msgstr ""
516516

517-
#: warehouse/manage/views/__init__.py:2438
517+
#: warehouse/manage/views/__init__.py:2431
518518
msgid "Team '${team_name}' already has ${role_name} role for project"
519519
msgstr ""
520520

521-
#: warehouse/manage/views/__init__.py:2545
521+
#: warehouse/manage/views/__init__.py:2538
522522
msgid "User '${username}' already has ${role_name} role for project"
523523
msgstr ""
524524

525-
#: warehouse/manage/views/__init__.py:2612
525+
#: warehouse/manage/views/__init__.py:2605
526526
msgid "${username} is now ${role} of the '${project_name}' project."
527527
msgstr ""
528528

529-
#: warehouse/manage/views/__init__.py:2644
529+
#: warehouse/manage/views/__init__.py:2637
530530
msgid ""
531531
"User '${username}' does not have a verified primary email address and "
532532
"cannot be added as a ${role_name} for project"
533533
msgstr ""
534534

535-
#: warehouse/manage/views/__init__.py:2657
535+
#: warehouse/manage/views/__init__.py:2650
536536
#: warehouse/manage/views/organizations.py:878
537537
msgid "User '${username}' already has an active invite. Please try again later."
538538
msgstr ""
539539

540-
#: warehouse/manage/views/__init__.py:2722
540+
#: warehouse/manage/views/__init__.py:2715
541541
#: warehouse/manage/views/organizations.py:943
542542
msgid "Invitation sent to '${username}'"
543543
msgstr ""
544544

545-
#: warehouse/manage/views/__init__.py:2755
545+
#: warehouse/manage/views/__init__.py:2748
546546
msgid "Could not find role invitation."
547547
msgstr ""
548548

549-
#: warehouse/manage/views/__init__.py:2766
549+
#: warehouse/manage/views/__init__.py:2759
550550
msgid "Invitation already expired."
551551
msgstr ""
552552

553-
#: warehouse/manage/views/__init__.py:2798
553+
#: warehouse/manage/views/__init__.py:2791
554554
#: warehouse/manage/views/organizations.py:1130
555555
msgid "Invitation revoked from '${username}'."
556556
msgstr ""

0 commit comments

Comments
 (0)