Skip to content

Commit cedb1d1

Browse files
committed
Limit the size of manifests/signatures sync/upload
Adds a limit to 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. closes: #532
1 parent 91eb742 commit cedb1d1

File tree

7 files changed

+146
-6
lines changed

7 files changed

+146
-6
lines changed

CHANGES/532.feature

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
A limit of 4 MB has been set for the size of manifests and signatures to protect against
2+
OOM DoS attacks during synchronization tasks and image uploads.
3+
Additionally, the Nginx snippet has been updated to enforce the limit for these endpoints.

pulp_container/app/downloaders.py

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

@@ -8,19 +9,80 @@
89
from logging import getLogger
910
from urllib import parse
1011

12+
1113
from pulpcore.plugin.download import DownloaderFactory, HttpDownloader
1214

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

1525
log = getLogger(__name__)
1626

1727
HeadResult = namedtuple(
1828
"HeadResult",
1929
["status_code", "path", "artifact_attributes", "url", "headers"],
2030
)
31+
DownloadResult = namedtuple("DownloadResult", ["url", "artifact_attributes", "path", "headers"])
32+
2133

34+
class ValidateResourceSizeMixin:
35+
async def _handle_response(self, response, content_type=None, max_body_size=None):
36+
"""
37+
Overrides the HttpDownloader method to be able to limit the request body size.
38+
Handle the aiohttp response by writing it to disk and calculating digests
39+
Args:
40+
response (aiohttp.ClientResponse): The response to handle.
41+
content_type (string): Type of the resource (manifest or signature) whose size
42+
will be verified
43+
max_body_size (int): Maximum allowed body size of the resource (manifest or signature).
44+
Returns:
45+
DownloadResult: Contains information about the result. See the DownloadResult docs for
46+
more information.
47+
"""
48+
if self.headers_ready_callback:
49+
await self.headers_ready_callback(response.headers)
50+
total_size = 0
51+
while True:
52+
chunk = await response.content.read(MEGABYTE)
53+
total_size += len(chunk)
54+
if max_body_size and total_size > max_body_size:
55+
await self.finalize()
56+
raise InvalidRequest(resource_body_size_exceeded_msg(content_type, max_body_size))
57+
if not chunk:
58+
await self.finalize()
59+
break # the download is done
60+
await self.handle_data(chunk)
61+
return DownloadResult(
62+
path=self.path,
63+
artifact_attributes=self.artifact_attributes,
64+
url=self.url,
65+
headers=response.headers,
66+
)
2267

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

109174
await response.release()
110175
self.response_headers = response.headers
@@ -193,7 +258,7 @@ async def _handle_head_response(self, response):
193258
)
194259

195260

196-
class NoAuthSignatureDownloader(HttpDownloader):
261+
class NoAuthSignatureDownloader(ValidateResourceSizeMixin, HttpDownloader):
197262
"""A downloader class suited for signature downloads."""
198263

199264
def raise_for_status(self, response):
@@ -208,6 +273,20 @@ def raise_for_status(self, response):
208273
else:
209274
response.raise_for_status()
210275

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

212291
class NoAuthDownloaderFactory(DownloaderFactory):
213292
"""

pulp_container/app/registry_api.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,11 @@
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,
94+
MANIFEST_PAYLOAD_MAX_SIZE,
9395
SIGNATURE_API_EXTENSION_VERSION,
9496
SIGNATURE_HEADER,
9597
SIGNATURE_PAYLOAD_MAX_SIZE,
@@ -1379,8 +1381,15 @@ def receive_artifact(self, chunk):
13791381
subchunk = chunk.read(2000000)
13801382
if not subchunk:
13811383
break
1382-
temp_file.write(subchunk)
13831384
size += len(subchunk)
1385+
if size > MANIFEST_PAYLOAD_MAX_SIZE:
1386+
temp_file.flush()
1387+
raise InvalidRequest(
1388+
message=resource_body_size_exceeded_msg(
1389+
"Manifest", MANIFEST_PAYLOAD_MAX_SIZE
1390+
)
1391+
)
1392+
temp_file.write(subchunk)
13841393
for algorithm in Artifact.DIGEST_FIELDS:
13851394
hashers[algorithm].update(subchunk)
13861395
temp_file.flush()

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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,6 @@
6969

7070
MEGABYTE = 1_000_000
7171
SIGNATURE_PAYLOAD_MAX_SIZE = 4 * MEGABYTE
72+
MANIFEST_PAYLOAD_MAX_SIZE = 4 * MEGABYTE
7273

7374
SIGNATURE_API_EXTENSION_VERSION = 2

0 commit comments

Comments
 (0)