Skip to content

Commit 452a2fa

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. To also prevent this during image upload, this commit configures a `client_max_body_size` for manifests and signatures Nginx endpoints. Modify the blob upload to read the layers in chunks. closes: pulp#532
1 parent 91eb742 commit 452a2fa

File tree

8 files changed

+171
-10
lines changed

8 files changed

+171
-10
lines changed

CHANGES/532.feature

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Added support to the `MANIFEST_PAYLOAD_MAX_SIZE` and `SIGNATURE_PAYLOAD_MAX_SIZE` settings to define
2+
limits (for the size of Manifests and Signatures) to protect against OOM DoS attacks during synchronization tasks
3+
and image uploads.
4+
Additionally, the Nginx snippet has been updated to enforce the limit for these endpoints.
5+
Modified the internal logic of Blob uploads to read the receiving layers in chunks,
6+
thereby reducing the memory footprint of the process.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Limit the size of Manifests and Signatures
2+
3+
It is possible to configure Pulp to block the synchronization and upload of image Manifests and/or
4+
Signatures if they exceed a defined size. A use case for this feature is to avoid OOM DoS attacks
5+
when synchronizing remote repositories with malicious or compromised containter images.
6+
To implement this, use the following settings:
7+
```
8+
MANIFEST_MAX_PAYLOAD_SIZE=<bytes>
9+
SIGNATURE_MAX_PAYLOAD_SIZE=<bytes>
10+
```
11+
12+
!!! info
13+
By default, there is no definition for these settings, meaning that no limit will be enforced.
14+
15+
16+
!!! note
17+
A common value adopted by other registries is to set these values to 4MB (4000000).

pulp_container/app/downloaders.py

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,87 @@
11
import aiohttp
22
import asyncio
3+
import fnmatch
34
import json
45
import re
56

67
from aiohttp.client_exceptions import ClientResponseError
78
from collections import namedtuple
9+
from django.conf import settings
810
from logging import getLogger
911
from urllib import parse
1012

13+
1114
from pulpcore.plugin.download import DownloaderFactory, HttpDownloader
1215

13-
from pulp_container.constants import V2_ACCEPT_HEADERS
16+
from pulp_container.constants import (
17+
MANIFEST_MEDIA_TYPES,
18+
MEGABYTE,
19+
V2_ACCEPT_HEADERS,
20+
)
21+
from pulp_container.app.exceptions import InvalidRequest
22+
from pulp_container.app.utils import resource_body_size_exceeded_msg
1423

1524
log = getLogger(__name__)
1625

1726
HeadResult = namedtuple(
1827
"HeadResult",
1928
["status_code", "path", "artifact_attributes", "url", "headers"],
2029
)
30+
DownloadResult = namedtuple("DownloadResult", ["url", "artifact_attributes", "path", "headers"])
31+
2132

33+
class ValidateResourceSizeMixin:
34+
async def _handle_response(self, response, content_type=None, max_body_size=None):
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+
content_type (string): Type of the resource (manifest or signature) whose size
41+
will be verified
42+
max_body_size (int): Maximum allowed body size of the resource (manifest or signature).
43+
Returns:
44+
DownloadResult: Contains information about the result. See the DownloadResult docs for
45+
more information.
46+
"""
47+
if self.headers_ready_callback:
48+
await self.headers_ready_callback(response.headers)
49+
total_size = 0
50+
while True:
51+
chunk = await response.content.read(MEGABYTE)
52+
total_size += len(chunk)
53+
if max_body_size and total_size > max_body_size:
54+
await self.finalize()
55+
raise InvalidRequest(resource_body_size_exceeded_msg(content_type, max_body_size))
56+
if not chunk:
57+
await self.finalize()
58+
break # the download is done
59+
await self.handle_data(chunk)
60+
return DownloadResult(
61+
path=self.path,
62+
artifact_attributes=self.artifact_attributes,
63+
url=self.url,
64+
headers=response.headers,
65+
)
2266

23-
class RegistryAuthHttpDownloader(HttpDownloader):
67+
def get_content_type_and_max_resource_size(self, response):
68+
"""
69+
Returns the content_type (manifest or signature) based on the HTTP request and also the
70+
corresponding resource allowed maximum size.
71+
"""
72+
max_resource_size = None
73+
content_type = response.content_type
74+
is_cosign_tag = fnmatch.fnmatch(response.url.name, "sha256-*.sig")
75+
if isinstance(self, NoAuthSignatureDownloader) or is_cosign_tag:
76+
max_resource_size = settings.get("SIGNATURE_PAYLOAD_MAX_SIZE", None)
77+
content_type = "Signature"
78+
elif content_type in MANIFEST_MEDIA_TYPES.IMAGE + MANIFEST_MEDIA_TYPES.LIST:
79+
max_resource_size = settings.get("MANIFEST_PAYLOAD_MAX_SIZE", None)
80+
content_type = "Manifest"
81+
return content_type, max_resource_size
82+
83+
84+
class RegistryAuthHttpDownloader(ValidateResourceSizeMixin, HttpDownloader):
2485
"""
2586
Custom Downloader that automatically handles Token Based and Basic Authentication.
2687
@@ -104,7 +165,10 @@ async def _run(self, handle_401=True, extra_data=None):
104165
if http_method == "head":
105166
to_return = await self._handle_head_response(response)
106167
else:
107-
to_return = await self._handle_response(response)
168+
content_type, max_resource_size = self.get_content_type_and_max_resource_size(
169+
response
170+
)
171+
to_return = await self._handle_response(response, content_type, max_resource_size)
108172

109173
await response.release()
110174
self.response_headers = response.headers
@@ -193,7 +257,7 @@ async def _handle_head_response(self, response):
193257
)
194258

195259

196-
class NoAuthSignatureDownloader(HttpDownloader):
260+
class NoAuthSignatureDownloader(ValidateResourceSizeMixin, HttpDownloader):
197261
"""A downloader class suited for signature downloads."""
198262

199263
def raise_for_status(self, response):
@@ -208,6 +272,20 @@ def raise_for_status(self, response):
208272
else:
209273
response.raise_for_status()
210274

275+
async def _run(self, extra_data=None):
276+
if self.download_throttler:
277+
await self.download_throttler.acquire()
278+
async with self.session.get(
279+
self.url, proxy=self.proxy, proxy_auth=self.proxy_auth, auth=self.auth
280+
) as response:
281+
self.raise_for_status(response)
282+
content_type, max_resource_size = self.get_content_type_and_max_resource_size(response)
283+
to_return = await self._handle_response(response, content_type, max_resource_size)
284+
await response.release()
285+
if self._close_session_on_finalize:
286+
await self.session.close()
287+
return to_return
288+
211289

212290
class NoAuthDownloaderFactory(DownloaderFactory):
213291
"""

pulp_container/app/registry_api.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,18 +87,20 @@
8787
filter_resource,
8888
has_task_completed,
8989
validate_manifest,
90+
resource_body_size_exceeded_msg,
9091
)
9192
from pulp_container.constants import (
9293
EMPTY_BLOB,
9394
SIGNATURE_API_EXTENSION_VERSION,
9495
SIGNATURE_HEADER,
95-
SIGNATURE_PAYLOAD_MAX_SIZE,
9696
SIGNATURE_TYPE,
9797
V2_ACCEPT_HEADERS,
98+
MEGABYTE,
9899
)
99100

100101
log = logging.getLogger(__name__)
101102

103+
102104
IGNORED_PULL_THROUGH_REMOTE_ATTRIBUTES = [
103105
"remote_ptr",
104106
"pulp_type",
@@ -790,7 +792,9 @@ def create_single_chunk_artifact(self, chunk):
790792
with transaction.atomic():
791793
# 1 chunk, create artifact right away
792794
with NamedTemporaryFile("ab") as temp_file:
793-
temp_file.write(chunk.read())
795+
while chunk and chunk.reader.length > 0:
796+
current_chunk = chunk.reader.read(MEGABYTE)
797+
temp_file.write(current_chunk)
794798
temp_file.flush()
795799

796800
uploaded_file = PulpTemporaryUploadedFile.from_file(
@@ -1379,8 +1383,16 @@ 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+
manifest_payload_max_size = settings.get("MANIFEST_PAYLOAD_MAX_SIZE", None)
1388+
if manifest_payload_max_size and size > manifest_payload_max_size:
1389+
temp_file.flush()
1390+
raise InvalidRequest(
1391+
message=resource_body_size_exceeded_msg(
1392+
"Manifest", manifest_payload_max_size
1393+
)
1394+
)
1395+
temp_file.write(subchunk)
13841396
for algorithm in Artifact.DIGEST_FIELDS:
13851397
hashers[algorithm].update(subchunk)
13861398
temp_file.flush()
@@ -1451,7 +1463,8 @@ def put(self, request, path, pk):
14511463
except models.Manifest.DoesNotExist:
14521464
raise ManifestNotFound(reference=pk)
14531465

1454-
signature_payload = request.META["wsgi.input"].read(SIGNATURE_PAYLOAD_MAX_SIZE)
1466+
signature_max_size = settings.get("SIGNATURE_PAYLOAD_MAX_SIZE", None)
1467+
signature_payload = request.META["wsgi.input"].read(signature_max_size)
14551468
try:
14561469
signature_dict = json.loads(signature_payload)
14571470
except json.decoder.JSONDecodeError:

pulp_container/app/tasks/sync_stages.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
ManifestSignature,
2828
Tag,
2929
)
30+
from pulp_container.app.exceptions import InvalidRequest
3031
from pulp_container.app.utils import (
3132
extract_data_from_signature,
3233
urlpath_sanitize,
@@ -62,7 +63,14 @@ 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 InvalidRequest as e:
69+
# if it failed to download the manifest, log the error and
70+
# there is nothing to return
71+
log.warning(e.args[0])
72+
return None, None, None
73+
6674
with open(response.path, "rb") as content_file:
6775
raw_bytes_data = content_file.read()
6876
response.artifact_attributes["file"] = response.path
@@ -146,6 +154,11 @@ async def run(self):
146154
for artifact in asyncio.as_completed(to_download_artifact):
147155
content_data, raw_text_data, response = await artifact
148156

157+
# skip the current object if it failed to be downloaded
158+
if not content_data:
159+
await pb_parsed_tags.aincrement()
160+
continue
161+
149162
digest = calculate_digest(raw_text_data)
150163
tag_name = response.url.split("/")[-1]
151164

@@ -542,6 +555,12 @@ async def create_signatures(self, man_dc, signature_source):
542555
"{} is not accessible, can't sync an image signature. "
543556
"Error: {} {}".format(signature_url, exc.status, exc.message)
544557
)
558+
except InvalidRequest as e:
559+
log.warning(
560+
"Failed to sync signature {}. Error: {}".format(signature_url, e.args[0])
561+
)
562+
signature_counter += 1
563+
continue
545564

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

pulp_container/app/utils.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,3 +342,10 @@ def filter_resources(element_list, include_patterns, exclude_patterns):
342342
if exclude_patterns:
343343
element_list = filter(partial(exclude, patterns=exclude_patterns), element_list)
344344
return list(element_list)
345+
346+
347+
def resource_body_size_exceeded_msg(content_type, max_resource_size):
348+
return (
349+
f"{content_type} body size exceeded the maximum allowed size of "
350+
f"{max_resource_size} bytes."
351+
)

pulp_container/app/webserver_snippets/nginx.conf

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,25 @@ location /token/ {
3838
proxy_redirect off;
3939
proxy_pass http://pulp-api;
4040
}
41+
42+
location ~* /v2/.*/manifests/.*$ {
43+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
44+
proxy_set_header X-Forwarded-Proto $scheme;
45+
proxy_set_header Host $http_host;
46+
# we don't want nginx trying to do something clever with
47+
# redirects, we set the Host: header above already.
48+
proxy_redirect off;
49+
proxy_pass http://pulp-api;
50+
client_max_body_size 4m;
51+
}
52+
53+
location ~* /extensions/v2/.*/signatures/.*$ {
54+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
55+
proxy_set_header X-Forwarded-Proto $scheme;
56+
proxy_set_header Host $http_host;
57+
# we don't want nginx trying to do something clever with
58+
# redirects, we set the Host: header above already.
59+
proxy_redirect off;
60+
proxy_pass http://pulp-api;
61+
client_max_body_size 4m;
62+
}

pulp_container/constants.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,5 @@
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

0 commit comments

Comments
 (0)