Skip to content

Commit dc730bf

Browse files
committed
🔒️(backend) address coderabbit findings + squash migrations
1 parent 00f9c51 commit dc730bf

15 files changed

Lines changed: 130 additions & 149 deletions

src/backend/core/api/viewsets/draft.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,8 @@ def finalize(self, request, pk=None):
428428
)
429429

430430
# Antivirus gate (fail closed): a draft only becomes a transfer once
431-
# every file is CLEAN. Two hard blocks: a virus, and a file that
431+
# every file has a non-blocking status — CLEAN, or scan-exempt
432+
# (SKIPPED / TOO_LARGE). Two hard blocks: a virus, and a file that
432433
# can't be scanned (error_kind="file") — a retry won't help, the
433434
# user must remove it. A *transient* error (clamd/scanner hiccup) is
434435
# re-submitted and kept polling so a passing failure doesn't brick

src/backend/core/api/viewsets/webhook.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
The endpoint is unauthenticated in the Django sense (the scanner has no
55
account) but protected by a per-file opaque secret minted at submission time
66
and echoed back in the query string — compared in constant time before any
7-
state change. The handler is idempotent: replaying the same callback (e.g. a
8-
scanner retry) simply re-writes the same ``scan_status``.
7+
state change. Updates are guarded to PENDING files, so a terminal verdict is
8+
final: a replayed or duplicate callback (a scanner retry, or a second scan job
9+
from the reaper) is a 200 no-op rather than an overwrite.
910
"""
1011

1112
import hmac
@@ -60,9 +61,20 @@ def post(self, request):
6061
new_status = self._status_from_payload(payload)
6162
error_kind = self._error_kind_from_payload(payload, new_status)
6263

63-
models.TransferFile.objects.filter(id=transfer_file.id).update(
64-
scan_status=new_status, scan_error_kind=error_kind
65-
)
64+
# Only a PENDING file is mutable — every legitimate result lands while a
65+
# scan is in flight. Guarding on it makes a terminal verdict final, so a
66+
# stale or duplicate callback (scanner retry, or a second reaper job)
67+
# can't overwrite it: fail closed, a virus stays a virus.
68+
updated = models.TransferFile.objects.filter(
69+
id=transfer_file.id, scan_status=ScanStatus.PENDING
70+
).update(scan_status=new_status, scan_error_kind=error_kind)
71+
if not updated:
72+
logger.info(
73+
"Scan result for file %s ignored — already %s",
74+
file_id,
75+
transfer_file.scan_status,
76+
)
77+
return Response(status=200)
6678
logger.info(
6779
"Scan result for file %s: %s%s",
6880
file_id,

src/backend/core/migrations/0004_transferfile_scan_fields.py

Lines changed: 0 additions & 68 deletions
This file was deleted.

src/backend/core/migrations/0005_alter_transferfile_scan_status.py

Lines changed: 0 additions & 18 deletions
This file was deleted.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Generated by Django 5.2.11 on 2026-06-23 09:21
2+
3+
from django.db import migrations, models
4+
5+
6+
def grandfather_existing_files_clean(apps, schema_editor):
7+
"""Existing *uploaded* files predate antivirus scanning — mark them CLEAN
8+
so they stay downloadable. Scope to ``upload_completed_at`` so an in-flight
9+
draft (upload not finished) keeps the PENDING default and is scanned for
10+
real when it later completes; otherwise it would slip through complete_upload
11+
as CLEAN, never scanned."""
12+
TransferFile = apps.get_model("core", "TransferFile")
13+
TransferFile.objects.filter(upload_completed_at__isnull=False).update(
14+
scan_status="clean"
15+
)
16+
17+
18+
def noop_reverse(apps, schema_editor):
19+
pass
20+
21+
22+
class Migration(migrations.Migration):
23+
24+
dependencies = [
25+
('core', '0004_alter_user_sub'),
26+
]
27+
28+
operations = [
29+
migrations.AddField(
30+
model_name='transferfile',
31+
name='scan_error_kind',
32+
field=models.CharField(blank=True, default='', help_text="Set only when scan_status is ERROR. 'transient' = an infrastructure failure that a retry may clear; 'file' = the file itself can't be scanned, so the user must remove it.", max_length=10),
33+
),
34+
migrations.AddField(
35+
model_name='transferfile',
36+
name='scan_job_id',
37+
field=models.CharField(blank=True, default='', help_text='Job id returned by the file-scanner service when the scan was submitted. Kept for traceability / debugging.', max_length=64),
38+
),
39+
migrations.AddField(
40+
model_name='transferfile',
41+
name='scan_status',
42+
field=models.CharField(choices=[('pending', 'Pending'), ('clean', 'Clean'), ('infected', 'Infected'), ('error', 'Error'), ('skipped', 'Skipped'), ('too_large', 'Too large to scan')], default='pending', help_text="Antivirus scan state. A file is downloadable when CLEAN or scan-exempt (SKIPPED / TOO_LARGE); the download path fails closed on anything else. Driven by the clamav file-scanner service's webhook callback.", max_length=10),
43+
),
44+
migrations.AddField(
45+
model_name='transferfile',
46+
name='webhook_secret',
47+
field=models.CharField(blank=True, default='', help_text='Per-file opaque token embedded in the scan callback URL. The scanner echoes it back; the webhook compares it (constant-time) before trusting the result. Generated when the scan is submitted.', max_length=64),
48+
),
49+
migrations.RunPython(
50+
grandfather_existing_files_clean, reverse_code=noop_reverse
51+
),
52+
]

src/backend/core/migrations/0006_alter_transferfile_scan_status.py

Lines changed: 0 additions & 18 deletions
This file was deleted.

src/backend/core/migrations/0007_transferfile_scan_error_kind.py

Lines changed: 0 additions & 27 deletions
This file was deleted.

src/backend/core/models.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -429,9 +429,10 @@ class TransferFile(BaseModel):
429429
max_length=10,
430430
choices=ScanStatus.choices,
431431
default=ScanStatus.PENDING,
432-
help_text="Antivirus scan state. A file is only downloadable once it "
433-
"is CLEAN; the download path fails closed on anything else. Driven by "
434-
"the clamav file-scanner service's webhook callback.",
432+
help_text="Antivirus scan state. A file is downloadable when CLEAN or "
433+
"scan-exempt (SKIPPED / TOO_LARGE); the download path fails closed on "
434+
"anything else. Driven by the clamav file-scanner service's webhook "
435+
"callback.",
435436
)
436437
scan_error_kind = models.CharField(
437438
max_length=10,

src/backend/core/tests/test_api_webhook.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,13 +98,27 @@ def test_error_with_bogus_kind_defaults_transient(self, api_client):
9898
f.refresh_from_db()
9999
assert f.scan_error_kind == "transient"
100100

101-
def test_recovery_clears_stale_kind(self, api_client):
102-
# A file that previously errored (file kind) comes back CLEAN on a
103-
# re-scan — the stale kind must be wiped, not left dangling.
104-
f = self._file(scan_status=ScanStatus.ERROR, scan_error_kind="file")
101+
def test_terminal_state_not_overwritten(self, api_client):
102+
# Once a file reaches a terminal verdict it is no longer PENDING, so a
103+
# stale or duplicate callback must not move it (fail closed).
104+
f = self._file(scan_status=ScanStatus.INFECTED)
105105
resp = _post(api_client, f.id, "s3cr3t", {"status": "done", "malware": False})
106106
assert resp.status_code == 200
107107
f.refresh_from_db()
108+
assert f.scan_status == ScanStatus.INFECTED
109+
110+
def test_duplicate_callback_cannot_flip_clean(self, api_client):
111+
# A second scan job (e.g. a reaper re-submit after a slow webhook) that
112+
# reports an error must not unset an already-CLEAN file.
113+
f = self._file(scan_status=ScanStatus.CLEAN)
114+
resp = _post(
115+
api_client,
116+
f.id,
117+
"s3cr3t",
118+
{"status": "error", "error_kind": "transient"},
119+
)
120+
assert resp.status_code == 200
121+
f.refresh_from_db()
108122
assert f.scan_status == ScanStatus.CLEAN
109123
assert f.scan_error_kind == ""
110124

src/backend/transferts/settings.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import dj_database_url
99
import sentry_sdk
1010
from configurations import Configuration, values
11+
from django.core.exceptions import ImproperlyConfigured
1112
from sentry_sdk.integrations.django import DjangoIntegration
1213

1314
logger = logging.getLogger(__name__)
@@ -603,6 +604,24 @@ def post_setup(cls):
603604
f"must be one of TRANSFER_EXPIRY_CHOICES ({cls.TRANSFER_EXPIRY_CHOICES})."
604605
)
605606

607+
# Fail fast: scanning enabled but its endpoints/credentials unset would
608+
# otherwise surface only later, as silently-skipped scans in the worker.
609+
if cls.CLAMAV_SCAN_ENABLED:
610+
missing = [
611+
name
612+
for name in (
613+
"CLAMAV_SERVICE_URL",
614+
"CLAMAV_API_KEY",
615+
"SCAN_WEBHOOK_BASE_URL",
616+
)
617+
if not getattr(cls, name)
618+
]
619+
if missing:
620+
raise ImproperlyConfigured(
621+
"CLAMAV_SCAN_ENABLED is set but these required settings are "
622+
f"empty: {', '.join(missing)}."
623+
)
624+
606625

607626
class Build(Base):
608627
"""Settings for building the application (not for running)."""

0 commit comments

Comments
 (0)