From 2f7e0252646c644ca4b3d1fba2fa37e457354947 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 16:52:54 +0000 Subject: [PATCH] fix(jike): close residual URL-injection gap, surface auth/export in help Follow-up hardening stacked on the ARFD-365 export/packaging work: - safe_url: reject URLs containing raw whitespace/newlines. clean() keeps "\n"/"\t", so an embedded newline in a link/image URL could break out of "[title](url)" and smuggle a second javascript:-scheme link onto the next line, bypassing the scheme allowlist on lenient Markdown renderers. - jike --help / -h: print the full command list including `auth` and `export`, which were intercepted before argparse and thus undiscoverable from top-level help. - .claude-plugin/marketplace.json: use the documented owner.url field instead of the non-standard owner.github key. - tests: cover newline-URL rejection, image size cap (header + streamed), non-image Content-Type rejection, and `jike --help`. https://claude.ai/code/session_01EhZKJSKTZZEnFJNeo6NWbG --- .claude-plugin/marketplace.json | 2 +- src/jike/__main__.py | 25 +++++++++++- src/jike/export_utils.py | 7 ++++ tests/test_export.py | 68 +++++++++++++++++++++++++++++++++ tests/test_main.py | 8 ++++ 5 files changed, 108 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 9dd958d..0cc1b7e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -2,7 +2,7 @@ "name": "jike-skill", "owner": { "name": "Alice and contributors", - "github": "MidnightDarling" + "url": "https://github.com/MidnightDarling" }, "metadata": { "description": "Jike (即刻) social network skill for AI agents — QR login, feed, posts, comments, search, export", diff --git a/src/jike/__main__.py b/src/jike/__main__.py index f198a22..9adeb1d 100644 --- a/src/jike/__main__.py +++ b/src/jike/__main__.py @@ -1,11 +1,34 @@ import sys +USAGE = ( + "Usage: jike [options]\n" + "\n" + "Commands:\n" + " auth QR login; print or --out tokens\n" + " feed Following feed\n" + " post Create a post\n" + " delete-post Delete a post\n" + " comment Comment on a post\n" + " delete-comment Delete a comment\n" + " search Search content\n" + " profile User profile\n" + " user-posts List a user's posts\n" + " notifications Unread + notification list\n" + " export Export a user's post history to Markdown\n" + "\n" + "Run 'jike --help' for command-specific options." +) + def main() -> None: if len(sys.argv) < 2: - print("Usage: jike ", file=sys.stderr) + print(USAGE, file=sys.stderr) sys.exit(1) + if sys.argv[1] in ("-h", "--help"): + print(USAGE) + return + if sys.argv[1] == "auth": from .auth import main as auth_main diff --git a/src/jike/export_utils.py b/src/jike/export_utils.py index 59b94d5..6c4f23b 100644 --- a/src/jike/export_utils.py +++ b/src/jike/export_utils.py @@ -42,6 +42,13 @@ def md_label(value: object) -> str: def safe_url(url: object) -> Optional[str]: text = clean(url).strip() + # A well-formed URL never contains raw whitespace. Rejecting it closes a + # Markdown-injection vector: clean() preserves "\n"/"\t", and an embedded + # newline in a link/image URL could break out of "[title](url)" and smuggle + # a second link (e.g. a javascript: scheme) onto the next line, bypassing the + # scheme allowlist below on lenient renderers. + if not text or any(ch.isspace() for ch in text): + return None parsed = urlparse(text) if parsed.scheme not in {"http", "https"} or not parsed.netloc: return None diff --git a/tests/test_export.py b/tests/test_export.py index 916b2d0..add8570 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -10,10 +10,12 @@ from jike.export import fetch_all_posts, fetch_user_profile from jike.export_utils import ( + MAX_IMAGE_BYTES, download_image, export_to_markdown, json_path_for, post_to_markdown, + safe_url, validate_username, ) @@ -155,6 +157,72 @@ def test_fetch_all_posts_writes_checkpoint(mock_fetch, tmp_path): assert saved["loadMoreKey"] is None +def test_safe_url_rejects_whitespace_and_newlines(): + assert safe_url("https://good.example.com/path") == "https://good.example.com/path" + assert safe_url("https://ok.example.com/has space") is None + assert safe_url("https://e.example.com/a\n[c](javascript:alert(1))") is None + + +def test_post_to_markdown_drops_url_with_embedded_newline(): + post = { + "id": "1", + "createdAt": "2026-01-01T00:00:00Z", + "content": "body", + "linkInfo": {"title": "t", "linkUrl": "https://e.example.com\n[c](javascript:alert(1))"}, + } + markdown = post_to_markdown(post, 1) + assert "javascript:" not in markdown + assert "](https://e.example.com" not in markdown + + +@patch("jike.export_utils.requests.get") +def test_download_image_rejects_oversize_content_length(mock_get, tmp_path): + mock_resp = MagicMock() + mock_resp.__enter__.return_value = mock_resp + mock_resp.__exit__.return_value = None + mock_resp.headers = {"Content-Type": "image/png", "Content-Length": str(MAX_IMAGE_BYTES + 1)} + mock_resp.iter_content.return_value = [b"abc"] + mock_resp.raise_for_status.return_value = None + mock_get.return_value = mock_resp + + result = download_image("https://cdn.ruguoapp.com/big.png", tmp_path / "images", tmp_path, 1, "orig_1") + + assert result is None + assert not (tmp_path / "images" / "post_0001_orig_1.png").exists() + + +@patch("jike.export_utils.requests.get") +def test_download_image_rejects_streamed_oversize(mock_get, tmp_path): + mock_resp = MagicMock() + mock_resp.__enter__.return_value = mock_resp + mock_resp.__exit__.return_value = None + # No Content-Length header; oversize is only detectable while streaming. + mock_resp.headers = {"Content-Type": "image/png"} + mock_resp.iter_content.return_value = [b"x" * (MAX_IMAGE_BYTES + 1)] + mock_resp.raise_for_status.return_value = None + mock_get.return_value = mock_resp + + result = download_image("https://cdn.ruguoapp.com/big.png", tmp_path / "images", tmp_path, 1, "orig_1") + + assert result is None + assert not (tmp_path / "images" / "post_0001_orig_1.png").exists() + + +@patch("jike.export_utils.requests.get") +def test_download_image_rejects_non_image_content_type(mock_get, tmp_path): + mock_resp = MagicMock() + mock_resp.__enter__.return_value = mock_resp + mock_resp.__exit__.return_value = None + mock_resp.headers = {"Content-Type": "text/html", "Content-Length": "3"} + mock_resp.iter_content.return_value = [b"abc"] + mock_resp.raise_for_status.return_value = None + mock_get.return_value = mock_resp + + result = download_image("https://cdn.ruguoapp.com/evil.png", tmp_path / "images", tmp_path, 1, "orig_1") + + assert result is None + + @patch("jike.export.fetch_user_posts") def test_fetch_all_posts_resumes_from_checkpoint(mock_fetch, tmp_path): checkpoint = tmp_path / "checkpoint.json" diff --git a/tests/test_main.py b/tests/test_main.py index a14831c..afcabb9 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -68,3 +68,11 @@ def test_export_dispatches_to_export_main(self): with patch("jike.export.main") as mock_export_main: main() mock_export_main.assert_called_once_with(["--username", "alice"]) + + @patch("jike.__main__.sys.argv", ["jike", "--help"]) + def test_help_flag_lists_all_commands(self, capsys): + main() + out = capsys.readouterr().out + assert "auth" in out + assert "export" in out + assert "feed" in out