Skip to content

Commit dbab226

Browse files
authored
fix: check that DE image url is well-formed (#1126)
1 parent 16a34da commit dbab226

File tree

4 files changed

+287
-17
lines changed

4 files changed

+287
-17
lines changed

poetry.lock

Lines changed: 24 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ xxhash = "3.4.*"
5757
pyjwt = { version = "2.7.*", extras = ["crypto"] }
5858
ecdsa = "0.18.*"
5959
distro = "^1.9.0"
60+
validators = "^0.34.0"
6061

6162
[tool.poetry.group.test.dependencies]
6263
pytest = "*"

src/aap_eda/core/validators.py

Lines changed: 101 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@
1313
# limitations under the License.
1414
import hashlib
1515
import logging
16+
import re
1617
import typing as tp
18+
import urllib
1719

20+
import validators
1821
import yaml
1922
from django.conf import settings
2023
from django.utils.translation import gettext_lazy as _
@@ -57,20 +60,110 @@ def check_if_de_exists(decision_environment_id: int) -> int:
5760

5861

5962
def check_if_de_valid(image_url: str, eda_credential_id: int):
63+
# The OCI standard format for the image url is a combination of a host
64+
# (with optional port) separated from the image path (with optional tag) by
65+
# a slash: <host>[:port]/<path>[:tag].
66+
#
67+
# https://github.com/opencontainers/distribution-spec/blob/8376368dd8aadc33bf6c88a8b765df90287bb5c8/spec.md?plain=1#L155 # noqa: E501
68+
#
69+
# We split the image url on the first slash into the host and path. The
70+
# path is further split into a name and tag on the rightmost colon.
71+
#
72+
# THe host portion is validated using the validators package and the path
73+
# and tag are validated using the OCI regexes for each.
74+
split = image_url.split("/", 1)
75+
host = split[0]
76+
path = split[1] if len(split) > 1 else None
77+
78+
if host == "":
79+
raise serializers.ValidationError(
80+
_("Image url %(image_url)s is malformed; no host name found")
81+
% {"image_url": image_url}
82+
)
83+
84+
# Yes, validators returns (not throws) an exception if the argument doesn't
85+
# pass muster (it returns True otherwise). Consequently we have to check
86+
# the class of the return to know what happened and if it's not validators'
87+
# validation exception raise whatever the heck it is.
88+
validity = validators.hostname(host)
89+
if isinstance(validity, Exception):
90+
if not isinstance(validity, validators.ValidationError):
91+
raise
92+
raise serializers.ValidationError(
93+
_(
94+
"Image url %(image_url)s is malformed; "
95+
"invalid host name: '%(host)s'"
96+
)
97+
% {"image_url": image_url, "host": host}
98+
)
99+
100+
if (path is None) or (path == ""):
101+
raise serializers.ValidationError(
102+
_("Image url %(image_url)s is malformed; no image path found")
103+
% {"image_url": image_url}
104+
)
105+
106+
split = path.split(":", 1)
107+
name = split[0]
108+
# Get the tag sans any additional content. Any additional content
109+
# is passed without validation.
110+
tag = split[1] if (len(split) > 1) else None
111+
tag = tag if tag is None else tag.split("@", 1)[0]
112+
113+
if not re.fullmatch(
114+
r"[[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*(\/[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*)*", # noqa: E501
115+
name,
116+
):
117+
raise serializers.ValidationError(
118+
_(
119+
"Image url %(image_url)s is malformed; "
120+
"'%(name)s' does not match OCI name standard"
121+
)
122+
% {"image_url": image_url, "name": name}
123+
)
124+
125+
if (tag is not None) and (
126+
not re.fullmatch(r"[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}", tag)
127+
):
128+
raise serializers.ValidationError(
129+
_(
130+
"Image url %(image_url)s is malformed; "
131+
"'%(tag)s' does not match OCI tag standard"
132+
)
133+
% {"image_url": image_url, "tag": tag}
134+
)
135+
60136
credential = get_credential_if_exists(eda_credential_id)
61137
inputs = yaml.safe_load(credential.inputs.get_secret_value())
62-
host = inputs.get("host")
138+
credential_host = inputs.get("host")
63139

64-
if not host:
140+
if not credential_host:
65141
raise serializers.ValidationError(
66-
f"Credential {credential.name} needs to have host information"
142+
_("Credential %(name)s needs to have host information")
143+
% {"name": credential.name}
67144
)
68145

69-
if not image_url.startswith(host):
70-
msg = (
71-
f"DecisionEnvironment image url: {image_url} does "
72-
f"not match with the credential host: {host}"
73-
)
146+
# Check that the host matches the credential host.
147+
# For backward compatibility when creating a new DE with an old credential
148+
# we need to separate any scheme from the host before doing the compare.
149+
parsed_credential_host = urllib.parse.urlparse(credential_host)
150+
# If there's a netloc that's the host to use; if not, it's the path if
151+
# there is no scheme else it's the scheme and path joined by a colon.
152+
if parsed_credential_host.netloc:
153+
parsed_host = parsed_credential_host.netloc
154+
else:
155+
parsed_host = parsed_credential_host.path
156+
if parsed_credential_host.scheme:
157+
parsed_host = ":".join(
158+
[parsed_credential_host.scheme, parsed_host]
159+
)
160+
161+
if host != parsed_host:
162+
msg = _(
163+
"DecisionEnvironment image url: %(image_url)s does "
164+
"not match with the credential host: %(host)s"
165+
) % {"image_url": image_url, "host": credential_host}
166+
74167
raise serializers.ValidationError(msg)
75168

76169

0 commit comments

Comments
 (0)