Skip to content

Commit 743533a

Browse files
committed
Limit the size of manifests/signatures sync/upload
Adds new settings to limit the size of manifests and signatures as a safeguard to avoid DDoS attack during sync and upload operations. Modify the blob upload to read the layers in chunks. closes: #532
1 parent bb7e18c commit 743533a

File tree

8 files changed

+146
-9
lines changed

8 files changed

+146
-9
lines changed

CHANGES/532.feature

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Added a limit of 4MB to non-Blob content, through the `OCI_PAYLOAD_MAX_SIZE` setting, to protect
2+
against OOM DoS attacks.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Limit the size of Manifests and Signatures
2+
3+
By default, Pulp is configured to block the synchronization of non-Blob content (Manifests,
4+
Signatures, etc.) if they exceed a 4MB size limit. A use case for this feature is to avoid
5+
OOM DoS attacks when synchronizing remote repositories with malicious or compromised container
6+
images.
7+
To define a different limit, use the following setting:
8+
```
9+
OCI_PAYLOAD_MAX_SIZE=<bytes>
10+
```
11+
12+
for example, to modify the limit to 10MB:
13+
```
14+
OCI_PAYLOAD_MAX_SIZE=10_000_000
15+
```

pulp_container/app/downloaders.py

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,85 @@
55

66
from aiohttp.client_exceptions import ClientResponseError
77
from collections import namedtuple
8+
from django.conf import settings
89
from logging import getLogger
910
from urllib import parse
1011

1112
from pulpcore.plugin.download import DownloaderFactory, HttpDownloader
1213

13-
from pulp_container.constants import V2_ACCEPT_HEADERS
14+
from pulp_container.constants import (
15+
CONTENT_TYPE_WITHOUT_SIZE_RESTRICTION,
16+
MEGABYTE,
17+
V2_ACCEPT_HEADERS,
18+
)
1419

1520
log = getLogger(__name__)
1621

1722
HeadResult = namedtuple(
1823
"HeadResult",
1924
["status_code", "path", "artifact_attributes", "url", "headers"],
2025
)
26+
DownloadResult = namedtuple("DownloadResult", ["url", "artifact_attributes", "path", "headers"])
27+
28+
29+
class PayloadTooLarge(ClientResponseError):
30+
"""Client exceeded the max allowed payload size."""
31+
32+
33+
class ValidateResourceSizeMixin:
34+
async def _handle_response(self, response):
35+
"""
36+
Overrides the HttpDownloader method to be able to limit the request body size.
37+
Handle the aiohttp response by writing it to disk and calculating digests
38+
Args:
39+
response (aiohttp.ClientResponse): The response to handle.
40+
Returns:
41+
DownloadResult: Contains information about the result. See the DownloadResult docs for
42+
more information.
43+
"""
44+
if self.headers_ready_callback:
45+
await self.headers_ready_callback(response.headers)
46+
total_size = 0
47+
while True:
48+
chunk = await response.content.read(MEGABYTE)
49+
total_size += len(chunk)
50+
max_body_size = self._get_max_allowed_resource_size(response)
51+
if max_body_size and total_size > max_body_size:
52+
self._ensure_no_broken_file()
53+
raise PayloadTooLarge(
54+
status=413,
55+
message="manifest invalid",
56+
request_info=response.request_info,
57+
history=response.history,
58+
)
59+
if not chunk:
60+
await self.finalize()
61+
break # the download is done
62+
await self.handle_data(chunk)
63+
return DownloadResult(
64+
path=self.path,
65+
artifact_attributes=self.artifact_attributes,
66+
url=self.url,
67+
headers=response.headers,
68+
)
69+
70+
def _get_max_allowed_resource_size(self, response):
71+
"""
72+
Returns the maximum allowed size for non-blob artifacts.
73+
"""
74+
75+
# content_type is defined by aiohttp based on the definition of the content-type header.
76+
# When it is not set, aiohttp defines it as "application/octet-stream"
77+
# note: http content-type header can be manipulated, making it easy to bypass this
78+
# size restriction, but checking the manifest content is also not a feasible solution
79+
# because we would need to first download it.
80+
if response.content_type in CONTENT_TYPE_WITHOUT_SIZE_RESTRICTION:
81+
return None
82+
83+
return settings["OCI_PAYLOAD_MAX_SIZE"]
2184

2285

23-
class RegistryAuthHttpDownloader(HttpDownloader):
86+
class RegistryAuthHttpDownloader(ValidateResourceSizeMixin, HttpDownloader):
2487
"""
2588
Custom Downloader that automatically handles Token Based and Basic Authentication.
2689
@@ -193,7 +256,7 @@ async def _handle_head_response(self, response):
193256
)
194257

195258

196-
class NoAuthSignatureDownloader(HttpDownloader):
259+
class NoAuthSignatureDownloader(ValidateResourceSizeMixin, HttpDownloader):
197260
"""A downloader class suited for signature downloads."""
198261

199262
def raise_for_status(self, response):

pulp_container/app/exceptions.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from rest_framework import status
12
from rest_framework.exceptions import APIException, NotFound, ParseError
23

34

@@ -151,3 +152,26 @@ def __init__(self, message):
151152
]
152153
}
153154
)
155+
156+
157+
class PayloadTooLarge(APIException):
158+
"""An exception to render an HTTP 413 response."""
159+
160+
status_code = status.HTTP_413_REQUEST_ENTITY_TOO_LARGE
161+
default_code = "manifest_invalid"
162+
163+
def __init__(self, message=None, code=None):
164+
"""Initialize the exception with the message for invalid size."""
165+
message = message or "payload too large"
166+
code = code or self.default_code
167+
super().__init__(
168+
detail={
169+
"errors": [
170+
{
171+
"code": code,
172+
"message": message,
173+
"detail": "http: request body too large",
174+
}
175+
]
176+
}
177+
)

pulp_container/app/registry_api.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
ManifestNotFound,
7070
ManifestInvalid,
7171
ManifestSignatureInvalid,
72+
PayloadTooLarge,
7273
)
7374
from pulp_container.app.redirects import (
7475
FileStorageRedirects,
@@ -90,9 +91,9 @@
9091
)
9192
from pulp_container.constants import (
9293
EMPTY_BLOB,
94+
MEGABYTE,
9395
SIGNATURE_API_EXTENSION_VERSION,
9496
SIGNATURE_HEADER,
95-
SIGNATURE_PAYLOAD_MAX_SIZE,
9697
SIGNATURE_TYPE,
9798
V2_ACCEPT_HEADERS,
9899
)
@@ -790,7 +791,8 @@ def create_single_chunk_artifact(self, chunk):
790791
with transaction.atomic():
791792
# 1 chunk, create artifact right away
792793
with NamedTemporaryFile("ab") as temp_file:
793-
temp_file.write(chunk.read())
794+
while subchunk := chunk.read(MEGABYTE):
795+
temp_file.write(subchunk)
794796
temp_file.flush()
795797

796798
uploaded_file = PulpTemporaryUploadedFile.from_file(
@@ -1157,6 +1159,8 @@ def fetch_manifest(self, remote, pk):
11571159
raise Throttled()
11581160
elif response_error.status == 404:
11591161
raise ManifestNotFound(reference=pk)
1162+
elif response_error.status == 413:
1163+
raise PayloadTooLarge()
11601164
else:
11611165
raise BadGateway(detail=response_error.message)
11621166
except (ClientConnectionError, TimeoutException):
@@ -1379,8 +1383,11 @@ def receive_artifact(self, chunk):
13791383
subchunk = chunk.read(2000000)
13801384
if not subchunk:
13811385
break
1382-
temp_file.write(subchunk)
13831386
size += len(subchunk)
1387+
if size > settings["OCI_PAYLOAD_MAX_SIZE"]:
1388+
temp_file.flush()
1389+
raise PayloadTooLarge()
1390+
temp_file.write(subchunk)
13841391
for algorithm in Artifact.DIGEST_FIELDS:
13851392
hashers[algorithm].update(subchunk)
13861393
temp_file.flush()
@@ -1451,7 +1458,7 @@ def put(self, request, path, pk):
14511458
except models.Manifest.DoesNotExist:
14521459
raise ManifestNotFound(reference=pk)
14531460

1454-
signature_payload = request.META["wsgi.input"].read(SIGNATURE_PAYLOAD_MAX_SIZE)
1461+
signature_payload = request.META["wsgi.input"].read(settings["OCI_PAYLOAD_MAX_SIZE"])
14551462
try:
14561463
signature_dict = json.loads(signature_payload)
14571464
except json.decoder.JSONDecodeError:

pulp_container/app/settings.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,8 @@
88

99
# The number of allowed threads to sign manifests in parallel
1010
MAX_PARALLEL_SIGNING_TASKS = 10
11+
12+
# Set max payload size for non-blob container artifacts (manifests, signatures, etc).
13+
# This limit is also valid for docker manifests, but we will use the OCI_ prefix
14+
# (instead of ARTIFACT_) to avoid confusion with pulpcore artifacts.
15+
OCI_PAYLOAD_MAX_SIZE = 4_000_000

pulp_container/app/tasks/sync_stages.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
SIGNATURE_TYPE,
2020
V2_ACCEPT_HEADERS,
2121
)
22+
from pulp_container.app.downloaders import PayloadTooLarge
2223
from pulp_container.app.models import (
2324
Blob,
2425
BlobManifest,
@@ -62,7 +63,12 @@ def __init__(self, remote, signed_only):
6263

6364
async def _download_manifest_data(self, manifest_url):
6465
downloader = self.remote.get_downloader(url=manifest_url)
65-
response = await downloader.run(extra_data={"headers": V2_ACCEPT_HEADERS})
66+
try:
67+
response = await downloader.run(extra_data={"headers": V2_ACCEPT_HEADERS})
68+
except PayloadTooLarge as e:
69+
log.warning(e.message + ": max size limit exceeded!")
70+
raise RuntimeError("Manifest max size limit exceeded.")
71+
6672
with open(response.path, "rb") as content_file:
6773
raw_bytes_data = content_file.read()
6874
response.artifact_attributes["file"] = response.path
@@ -542,6 +548,12 @@ async def create_signatures(self, man_dc, signature_source):
542548
"{} is not accessible, can't sync an image signature. "
543549
"Error: {} {}".format(signature_url, exc.status, exc.message)
544550
)
551+
except PayloadTooLarge as e:
552+
log.warning(
553+
"Failed to sync signature {}. Error: {}".format(signature_url, e.args[0])
554+
)
555+
signature_counter += 1
556+
continue
545557

546558
with open(signature_download_result.path, "rb") as f:
547559
signature_raw = f.read()

pulp_container/constants.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,15 @@
6868
SIGNATURE_HEADER = "X-Registry-Supports-Signatures"
6969

7070
MEGABYTE = 1_000_000
71-
SIGNATURE_PAYLOAD_MAX_SIZE = 4 * MEGABYTE
7271

7372
SIGNATURE_API_EXTENSION_VERSION = 2
73+
74+
BINARY_CONTENT_TYPE = "binary/octet-stream"
75+
JSON_CONTENT_TYPE = "application/json"
76+
77+
# Any content-type that should not be limited by OCI_PAYLOAD_MAX_SIZE
78+
CONTENT_TYPE_WITHOUT_SIZE_RESTRICTION = [
79+
BINARY_CONTENT_TYPE,
80+
BLOB_CONTENT_TYPE,
81+
JSON_CONTENT_TYPE,
82+
]

0 commit comments

Comments
 (0)