Skip to content

Commit 3cba896

Browse files
authored
Merge pull request #7850 from freedomofpress/reply-sent-statuses
fix(`handle_reply_sent`): return HTTP `400` or `409`, respectively, for a reply unencrypted or with a duplicate UUID
2 parents d06d29c + a8c33a2 commit 3cba896

3 files changed

Lines changed: 110 additions & 6 deletions

File tree

securedrop/journalist_app/api2/events.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
from journalist_app.sessions import Session, session
1515
from models import Reply, Source, Submission
1616
from redis import Redis
17+
from sqlalchemy.exc import IntegrityError
1718
from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
19+
from store import NotEncrypted
1820

1921
# `IDEMPOTENCE_PERIOD` MUST be greater than or equal to
2022
# `sdconfig.SecureDropConfig.SESSION_LIFETIME`. In practice, 24 hours is the
@@ -163,14 +165,37 @@ def handle_reply_sent(event: Event, minor: int) -> EventResult:
163165
),
164166
)
165167

166-
reply = save_reply(source, asdict(event.data))
167-
db.session.refresh(source)
168+
# SQLite will enforce uniqueness within Reply.uuid, but we need
169+
# uniqueness within the combined set of Reply.uuid and Submission.uuid.
170+
if find_item(event.data.uuid) is not None:
171+
return EventResult(
172+
event_id=event.id,
173+
status=(EventStatusCode.Conflict, "duplicate UUID"),
174+
)
175+
176+
try:
177+
reply = save_reply(source, asdict(event.data))
178+
db.session.refresh(source)
179+
180+
return EventResult(
181+
event_id=event.id,
182+
status=(EventStatusCode.OK, None),
183+
sources={source.uuid: source},
184+
items={reply.uuid: reply},
185+
)
186+
except NotEncrypted:
187+
db.session.rollback()
188+
status = (EventStatusCode.BadRequest, "reply is not encrypted")
189+
except IntegrityError:
190+
db.session.rollback()
191+
status = (EventStatusCode.Conflict, "duplicate UUID")
192+
# `save_reply()` can also raise `InvalidUUID`, but this will have been
193+
# caught by validation of the `ReplySentData` type before this handler
194+
# is ever invoked.
168195

169196
return EventResult(
170197
event_id=event.id,
171-
status=(EventStatusCode.OK, None),
172-
sources={source.uuid: source},
173-
items={reply.uuid: reply},
198+
status=status,
174199
)
175200

176201
@staticmethod

securedrop/journalist_app/api2/types.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ class EventStatusCode(IntEnum):
4646
BadRequest = 400
4747
# The target UUID doesn't exist (non-deletion requests)
4848
NotFound = 404
49-
# Provided version is out of date and it was a deletion request
49+
# Version is out of date or the target UUID is already in use
5050
Conflict = 409
5151
# The target UUID doesn't exist and it was a deletion request
5252
Gone = 410

securedrop/tests/test_journalist_api2.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,85 @@ def test_api2_reply_sent(
571571
assert reply2["uuid"] not in response.json["items"]
572572

573573

574+
def test_api2_reply_sent_unencrypted(journalist_app, journalist_api_token, test_files):
575+
"""handle_reply_sent returns 400 BadRequest if the reply is not PGP-encrypted."""
576+
with journalist_app.test_client() as app:
577+
source = test_files["source"]
578+
index = app.get(url_for("api2.index"), headers=get_api_headers(journalist_api_token))
579+
source_version = index.json["sources"][source.uuid]
580+
581+
event = Event(
582+
id="400001",
583+
target=SourceTarget(source_uuid=source.uuid, version=source_version),
584+
type=EventType.REPLY_SENT,
585+
data={"uuid": str(uuid.uuid4()), "reply": "not a pgp message"},
586+
)
587+
response = app.post(
588+
url_for("api2.data"),
589+
json={"events": [asdict(event)]},
590+
headers=get_api_headers(journalist_api_token),
591+
)
592+
assert response.json["events"][event.id][0] == 400
593+
594+
595+
def test_api2_reply_sent_duplicate_uuid(journalist_app, journalist_api_token, test_files):
596+
"""handle_reply_sent returns 409 Conflict if save_reply() would commit a duplicate UUID."""
597+
with journalist_app.test_client() as app:
598+
source = test_files["source"]
599+
reply = test_files["replies"][0]
600+
601+
reply_ct = app.get(
602+
url_for("api.download_reply", source_uuid=source.uuid, reply_uuid=reply.uuid),
603+
headers=get_api_headers(journalist_api_token),
604+
).data
605+
606+
index = app.get(url_for("api2.index"), headers=get_api_headers(journalist_api_token))
607+
source_version = index.json["sources"][source.uuid]
608+
609+
shared_uuid = str(uuid.uuid4())
610+
event1 = Event(
611+
id="409001",
612+
target=SourceTarget(source_uuid=source.uuid, version=source_version),
613+
type=EventType.REPLY_SENT,
614+
data={"uuid": shared_uuid, "reply": ascii_armor(reply_ct)},
615+
)
616+
response1 = app.post(
617+
url_for("api2.data"),
618+
json={"events": [asdict(event1)]},
619+
headers=get_api_headers(journalist_api_token),
620+
)
621+
assert response1.json["events"]["409001"] == [200, None]
622+
623+
# A different event that tries to commit the same reply UUID:
624+
event2 = Event(
625+
id="409002",
626+
target=SourceTarget(source_uuid=source.uuid, version=source_version),
627+
type=EventType.REPLY_SENT,
628+
data={"uuid": shared_uuid, "reply": ascii_armor(reply_ct)},
629+
)
630+
response2 = app.post(
631+
url_for("api2.data"),
632+
json={"events": [asdict(event2)]},
633+
headers=get_api_headers(journalist_api_token),
634+
)
635+
assert response2.json["events"]["409002"][0] == 409
636+
637+
# A different event that tries to use a submission's UUID as the reply UUID:
638+
submission = test_files["submissions"][0]
639+
event3 = Event(
640+
id="409003",
641+
target=SourceTarget(source_uuid=source.uuid, version=source_version),
642+
type=EventType.REPLY_SENT,
643+
data={"uuid": submission.uuid, "reply": ascii_armor(reply_ct)},
644+
)
645+
response3 = app.post(
646+
url_for("api2.data"),
647+
json={"events": [asdict(event3)]},
648+
headers=get_api_headers(journalist_api_token),
649+
)
650+
assert response3.json["events"]["409003"][0] == 409
651+
652+
574653
def test_api2_item_deleted(
575654
journalist_app,
576655
journalist_api_token,

0 commit comments

Comments
 (0)