Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions ctfcli/core/challenge.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
InvalidChallengeDefinition,
InvalidChallengeFile,
LintException,
ProjectNotInitialized,
RemoteChallengeNotFound,
)
from ctfcli.core.image import Image
from ctfcli.core.media import Media
from ctfcli.utils.hashing import hash_file
from ctfcli.utils.tools import strings

Expand Down Expand Up @@ -365,12 +367,36 @@ def _validate_files(self):
if not (self.challenge_directory / challenge_file).exists():
raise InvalidChallengeFile(f"File {challenge_file} could not be loaded")

def _render_media(self, text):
# Substitute [media] placeholders from .ctf/config; no-op if text isn't a
# string or the project config can't be located (e.g. outside a project).
if not isinstance(text, str):
return text
try:
return Media.replace_placeholders(text)
except ProjectNotInitialized:
return text

def _render_media_in_hints(self, hints):
# Return a copy of the hints list with [media] placeholders rendered in
# each hint's content, preserving the str-vs-dict structure used by
# challenge.yml / _normalize_challenge.
rendered = []
for h in hints:
if isinstance(h, str):
rendered.append(self._render_media(h))
elif isinstance(h, dict) and "content" in h:
rendered.append({**h, "content": self._render_media(h["content"])})
else:
rendered.append(h)
return rendered

def _get_initial_challenge_payload(self, ignore: tuple[str] = ()) -> dict:
challenge = self
challenge_payload = {
"name": self["name"],
"category": self.get("category", ""),
"description": self.get("description", ""),
"description": self._render_media(self.get("description", "") or ""),
"attribution": self.get("attribution", ""),
"type": self.get("type", "standard"),
# Hide the challenge for the duration of the sync / creation
Expand Down Expand Up @@ -511,7 +537,7 @@ def _create_hints(self):
for idx, hint in enumerate(self["hints"]):
if type(hint) == str:
hint_payload = {
"content": hint,
"content": self._render_media(hint),
"title": "",
"cost": 0,
"challenge_id": self.challenge_id,
Expand All @@ -520,7 +546,7 @@ def _create_hints(self):
else:
has_requirements = bool(hint.get("requirements"))
hint_payload = {
"content": "" if has_requirements else hint["content"],
"content": "" if has_requirements else self._render_media(hint["content"]),
"title": hint.get("title", ""),
"cost": hint.get("cost", 0),
"challenge_id": self.challenge_id,
Expand Down Expand Up @@ -567,7 +593,7 @@ def _create_hints(self):
# Now safe to set the real content
r = self.api.patch(
f"/api/v1/hints/{hint_id}",
json={"content": hint["content"]},
json={"content": self._render_media(hint["content"])},
)
r.raise_for_status()

Expand Down Expand Up @@ -1457,6 +1483,15 @@ def verify(self, ignore: tuple[str] = ()) -> bool:
if key == "module" and self._compare_challenge_module(challenge[key], normalized_challenge[key]):
continue

# Render [media] placeholders locally before comparing, so a
# challenge pushed with placeholders still verifies against the
# substituted values stored on the remote.
if key == "description" and self._render_media(challenge[key]) == normalized_challenge[key]:
continue

if key == "hints" and self._render_media_in_hints(challenge[key]) == normalized_challenge[key]:
continue

click.secho(
f"{key} comparison failed.",
fg="yellow",
Expand Down
152 changes: 152 additions & 0 deletions tests/core/test_challenge.py
Original file line number Diff line number Diff line change
Expand Up @@ -2510,6 +2510,158 @@ def test_mirror_challenge(self, mock_api_constructor: MagicMock):
loaded_data = yaml.safe_load(dumped_data)
self.assertDictEqual(expected_challenge, loaded_data)

@mock.patch(
"ctfcli.core.config.Path.cwd",
return_value=BASE_DIR / "fixtures" / "challenges" / "test-challenge-full",
)
@mock.patch("ctfcli.core.challenge.API")
def test_verify_renders_media_placeholders(self, mock_api_constructor: MagicMock, *args, **kwargs):
# The remote stores the substituted values, so verify must render the
# local [media] placeholders before comparing to avoid a false mismatch.
def media_get(*get_args, **get_kwargs):
response = self.mock_get(*get_args, **get_kwargs)
path = get_args[0]

if path in ("/api/v1/challenges/3", "/api/v1/challenges/3?view=admin"):
response.json.return_value["data"]["description"] = "/files/media/logo.png"

if path == "/api/v1/challenges/3/hints":
response.json.return_value["data"][0]["content"] = "/files/media/handout.zip"

return response

mock_api: MagicMock = mock_api_constructor.return_value
mock_api.get.side_effect = media_get

challenge = Challenge(
self.full_challenge,
{
"description": "{logo}",
"hints": ["{handout}", {"content": "paid hint", "cost": 100}],
},
)
challenge.challenge_id = 3

self.assertTrue(challenge.verify(ignore=["files"]))


class TestMediaPlaceholders(unittest.TestCase):
minimal_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal" / "challenge.yml"
minimal_challenge_cwd = BASE_DIR / "fixtures" / "challenges" / "test-challenge-minimal"

installed_challenges = [{"id": 1, "name": "Test Challenge"}]

@mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd)
@mock.patch("ctfcli.core.challenge.API")
def test_create_substitutes_media_in_description(self, mock_api_constructor: MagicMock, *args, **kwargs):
challenge = Challenge(self.minimal_challenge, {"description": "See {logo}", "state": "hidden"})

mock_api: MagicMock = mock_api_constructor.return_value
mock_api.post.return_value.json.return_value = {"success": True, "data": {"id": 1}}

challenge.create()

create_call = mock_api.post.call_args_list[0]
self.assertEqual(create_call.args[0], "/api/v1/challenges")
self.assertEqual(create_call.kwargs["json"]["description"], "See /files/media/logo.png")

@mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd)
@mock.patch(
"ctfcli.core.challenge.Challenge.load_installed_challenge",
return_value={"id": 1, "name": "Test Challenge", "state": "hidden", "files": []},
)
@mock.patch("ctfcli.core.challenge.Challenge.load_installed_challenges", return_value=installed_challenges)
@mock.patch("ctfcli.core.challenge.API")
def test_sync_substitutes_media_in_description(self, mock_api_constructor: MagicMock, *args, **kwargs):
challenge = Challenge(self.minimal_challenge, {"description": "See {logo}", "state": "hidden"})

mock_api: MagicMock = mock_api_constructor.return_value
mock_api.get.return_value.json.return_value = {"success": True, "data": []}

challenge.sync(ignore=["files"])

mock_api.patch.assert_any_call(
"/api/v1/challenges/1",
json={
"name": "Test Challenge",
"category": "New Test",
"description": "See /files/media/logo.png",
"attribution": "New Test Attribution",
"type": "standard",
"value": 150,
"state": "hidden",
"max_attempts": 0,
"connection_info": None,
"scheduled_at": None,
},
)

@mock.patch("ctfcli.core.config.Path.cwd", return_value=minimal_challenge_cwd)
@mock.patch("ctfcli.core.challenge.API")
def test_create_hints_substitutes_media(self, mock_api_constructor: MagicMock, *args, **kwargs):
challenge = Challenge(
self.minimal_challenge,
{
"hints": [
"download {handout}",
{"content": "logo {logo}", "cost": 50},
{"key": "h1", "content": "base {logo}"},
{"key": "h2", "content": "deeper {handout}", "requirements": ["h1"]},
]
},
)
challenge.challenge_id = 1

mock_api: MagicMock = mock_api_constructor.return_value
post_responses = []
for hint_id in range(10, 14):
response = MagicMock()
response.json.return_value = {"success": True, "data": {"id": hint_id}}
post_responses.append(response)
mock_api.post.side_effect = post_responses

challenge._create_hints()

# plain-string hint content is substituted
mock_api.post.assert_any_call(
"/api/v1/hints",
json={
"content": "download /files/media/handout.zip",
"title": "",
"cost": 0,
"challenge_id": 1,
},
)
# dict hint content is substituted
mock_api.post.assert_any_call(
"/api/v1/hints",
json={"content": "logo /files/media/logo.png", "title": "", "cost": 50, "challenge_id": 1},
)
# requirement-gated hint is posted blank, then its real (substituted) content is patched in
mock_api.post.assert_any_call(
"/api/v1/hints",
json={"content": "", "title": "", "cost": 0, "challenge_id": 1},
)
mock_api.patch.assert_any_call(
"/api/v1/hints/13",
json={"content": "deeper /files/media/handout.zip"},
)

@mock.patch("ctfcli.core.config.Path.cwd", return_value=Path("/"))
@mock.patch("ctfcli.core.challenge.API")
def test_no_substitution_without_project(self, mock_api_constructor: MagicMock, *args, **kwargs):
# Outside of a project (no .ctf/config), substitution is a defensive no-op
# and the raw token is sent unchanged.
challenge = Challenge(self.minimal_challenge, {"description": "See {logo}", "state": "hidden"})

mock_api: MagicMock = mock_api_constructor.return_value
mock_api.post.return_value.json.return_value = {"success": True, "data": {"id": 1}}

challenge.create()

create_call = mock_api.post.call_args_list[0]
self.assertEqual(create_call.kwargs["json"]["description"], "See {logo}")


class TestSaveChallenge(unittest.TestCase):
full_challenge = BASE_DIR / "fixtures" / "challenges" / "test-challenge-full" / "challenge.yml"
Expand Down
4 changes: 4 additions & 0 deletions tests/core/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ def test_returns_correct_json_representation(self, *args, **kwargs):
"test-challenge-full": "user@host:example/test-challenge-full.git",
"test-challenge-minimal": "user@host:example/test-challenge-minimal.git",
},
"media": {
"logo": "/files/media/logo.png",
"handout": "/files/media/handout.zip",
},
}

config_data = json.loads(config.as_json())
Expand Down
4 changes: 4 additions & 0 deletions tests/fixtures/challenges/.ctf/config
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ test-challenge-full = user@host:example/test-challenge-full.git
test-challenge-files = user@host:example/test-challenge-files.git
test-challenge-dockerfile = user@host:example/test-challenge-dockerfile.git

[media]
logo = /files/media/logo.png
handout = /files/media/handout.zip