[api][runtime][java][python] Require HTTPS and add digest pinning for URL skill sources - #1005
[api][runtime][java][python] Require HTTPS and add digest pinning for URL skill sources#1005rob-9 wants to merge 17 commits into
Conversation
weiqingy
left a comment
There was a problem hiding this comment.
Thanks for taking this on. A few questions inline.
| @@ -159,8 +167,31 @@ def download_to_tempfile(url: str, timeout: int = 90) -> Path: | |||
| tmp_path = Path(tmp_path_str) | |||
| try: | |||
| with urlopen(req, timeout=timeout) as resp, tmp_path.open("wb") as out: | |||
There was a problem hiding this comment.
urlopen here uses the default opener, and its redirect handler allows http, https and ftp targets. So when an HTTPS source redirects to http://…, Python performs the plaintext GET and only rejects it afterwards at :191-193. An ftp:// location gets dialled too, since FTPHandler is installed by default.
No archive bytes are written, so the content is never trusted. But the request itself, URL and headers included, does travel over the downgraded connection. Java never gets that far — HttpURLConnection refuses the cross-protocol redirect, so the second request is never made.
The test covering this (test_materialize.py:164) swaps urlopen for a stub that reports a different geturl(), so it checks the string comparison rather than urllib's redirect handling. This case would slip past it.
What do you think about declining the redirect in a custom opener, so the request is never issued? A real HTTPServer returning a 302 to an http:// location, the way SkillMaterializerTest.java:153 does on the Java side, would cover it end to end.
There was a problem hiding this comment.
agreed here - added a handler that rejects protocol changes before the target request is sent, including redirects to HTTP or FTP. also replaced the stubbed test with a real HTTPServer test.
| Files.copy(in, tmpZip, StandardCopyOption.REPLACE_EXISTING); | ||
| try { | ||
| int responseCode = conn.getResponseCode(); | ||
| if (responseCode >= 300 && responseCode < 400) { |
There was a problem hiding this comment.
This catches a redirect that changes protocol, but a same-protocol redirect is followed silently and the final URL is never checked. Java only looks at the protocol of the configured URL (:267-272), never at conn.getURL(); Python compares just the scheme of resp.geturl() (_materialize.py:189-196), not the host.
So for an unpinned HTTPS source, an open redirect at the configured host can pull the archive from somewhere else, and nothing records it — SkillOrigin (SkillSourceRegistry.java:56) still reports the URL that was configured. Since the archive becomes agent instructions and possibly scripts, where the bytes actually came from seems worth surfacing.
Pinning a sha256 does catch this. Is that the intended answer, or would logging the final URL when it differs be worth adding?
One related note: the protection against a scheme change here is real but implicit, coming from the JDK declining cross-protocol redirects rather than from this code. A later move to java.net.http.HttpClient would reverse it, since its default policy follows those. Might be worth a comment naming that.
There was a problem hiding this comment.
pinning remains the integrity control, but surfacing redirects is useful too. added URL logging in both runtimes and documented Java's cross-protocol behavior,
| } | ||
|
|
||
| @Test | ||
| void sha256MismatchRejectedBeforeExtraction(@TempDir Path tempDir) throws IOException { |
There was a problem hiding this comment.
The name says BeforeExtraction, but the body only checks the exception and its message. If the digest check moved to after extractZipSafely, the archive would extract, the same IllegalArgumentException with the same "SHA-256 mismatch" text would still be thrown, and this test would stay green. Python's test_sha256_mismatch_rejected (test_url_repository.py:103) has the same gap, just without the name promising more.
Checking before extraction is the property #1003 actually asks for, so a test that turns red when it changes seems worth having. One option: serve an archive that extraction itself would reject — the zip-slip fixture at SkillMaterializerTest.java:72 — and assert the failure is the digest error rather than "Unsafe zip entry". Flip the order and you would get the zip-slip error instead.
Any reason that wouldn't work here?
There was a problem hiding this comment.
no reason, that works well. updated tests so they now fail if extraction happens first.
| for (String u : spec.getUrls()) { | ||
| sources.add(new SkillSourceSpec("url", Map.of("url", u))); | ||
| for (String url : spec.getUrls()) { | ||
| sources.add(new SkillSourceSpec("url", Map.of("url", url))); |
There was a problem hiding this comment.
This builds the source without running the URL check, and loader.py:208-210 does the same. So urls: [http://x/s.zip] parses cleanly and only fails later on the TaskManager, while Skills.fromUrl("http://…") fails right away. A malformed sha256 under url_sources behaves the same way.
Both languages do this identically, so it isn't a parity issue. It's that the same mistake surfaces at very different moments depending on how you configure it, and the YAML user finds out last. Nothing on either side tests that a YAML http:// entry is rejected, either.
Was that deliberate, keeping the loaders out of transport policy, or did it just work out that way?
There was a problem hiding this comment.
it wasn't deliberate. both YAML loaders now use the validated URL factories, so plain HTTP and malformed digests fail during loading while explicit HTTP opt-in remains supported.
| | `name` | yes | Skills resource name. | | ||
| | `paths` | one-of | `local` scheme: list of directories or `.zip` files. | | ||
| | `urls` | one-of | `url` scheme: list of `http(s)` URLs pointing to `.zip` archives. | | ||
| | `urls` | one-of | `url` scheme: list of HTTPS URLs pointing to `.zip` archives. | |
There was a problem hiding this comment.
urls: [http://…] worked before this PR and now fails, and neither doc says what to switch to. The answer also differs by surface: in code there's from_url_unsafe / fromUrlUnsafe (skills.md:145), but urls has no opt-in at all — the entry has to move into url_sources with allow_insecure_http: true. That's a different shape rather than a flag, so it's the harder one to guess from the error alone.
Something like this after the row, if it helps: "urls entries must be HTTPS. To keep an existing plain-HTTP source working, move it to url_sources with allow_insecure_http: true."
Does that feel like doc material, or more of a release-note thing?
There was a problem hiding this comment.
probably both. the migration path belongs in the YAML docs, while the compatibility change belongs in release notes. I added the YAML migration example here.
| } | ||
|
|
||
| @Test | ||
| void pinnedUrlRoundTripsThroughJackson() throws Exception { |
There was a problem hiding this comment.
nit: this round trip stops at sha256, and test_skills.py::test_serialize_roundtrip does the same, so allow_insecure_http isn't covered on either side.
I compared what the two languages emit and they match exactly — both write the flag as the string "true" and omit it otherwise, and both read it back the same way — so there's no bug here today. The only gap is that nothing would catch it if they drifted. Raising it because #1003 mentions alignment "including plan serialization".
Would adding the flag to this test and its Python counterpart be enough?
There was a problem hiding this comment.
agreed. extended both round-trip tests to cover allow_insecure_http and verify it remains the string "true" thru serialization and deserialization.
weiqingy
left a comment
There was a problem hiding this comment.
Thanks for addressing the comments. While going back through the updated code I spotted a few smaller things, all inline.
| conn.setRequestMethod("GET"); | ||
| // HttpURLConnection follows same-protocol redirects but leaves cross-protocol redirects | ||
| // unfollowed. Any future HTTP client must preserve that restriction. | ||
| conn.setInstanceFollowRedirects(true); |
There was a problem hiding this comment.
The comment is accurate, the JDK really does decline a scheme-changing redirect: followRedirect() compares the two protocols and bails before taking it.
The call itself isn't quite a no-op though. instanceFollowRedirects is initialised from the static followRedirects, so if anything in the JVM has called HttpURLConnection.setFollowRedirects(false), which some deployments do as a hardening step, this line overrides that and re-enables redirect following for skill downloads specifically. I checked on temurin-11: after setFollowRedirects(false) the instance default reads back false, and this line flips it to true. Without it the 3xx would surface and :284 would fail closed with a clear message.
Was pinning it to true deliberate, or is the comment doing the real work here?
There was a problem hiding this comment.
good catch. this wasn't intentional, removed the override and added a test to confirm.
| } | ||
|
|
||
| @Test | ||
| void rejectsPlainHttpSkillUrlDuringLoading(@TempDir Path tmp) throws Exception { |
There was a problem hiding this comment.
nit: this covers the urls: list, and the new four-way test in YamlLoaderBuildersTest covers the url_sources shapes that succeed. The case #1003 is actually about, url_sources: [{url: http://…}] with allow_insecure_http absent, isn't asserted on either side, and the issue asks for "focused Java and Python tests for HTTP rejection or opt-in behavior".
The positive cases do pin the factory choice indirectly, so this is coverage rather than a hole. Worth one line per language?
There was a problem hiding this comment.
added focused Java and Python tests for plain HTTP in url_sources without the opt-in.
| if not isinstance(url, str): | ||
| msg = "skill URL must be a string" | ||
| raise TypeError(msg) | ||
| parsed = urlparse(url) |
There was a problem hiding this comment.
urlparse doesn't reject a malformed URL, so https://exa mple.com/x.zip is accepted here and only fails later at download time, while Skills.java:163-168 runs the same string through URI.create and rethrows it as Invalid skill URL: at call time.
Before these commits neither YAML loader reached either validator. Now both do, so the split is exercised at load time on both runtimes rather than only through the code API.
Is the later failure good enough here, or would you rather Python rejected it at the same point Java does?
There was a problem hiding this comment.
aligned Python with Java so malformed URLs fail immediately. also added coverage for whitespace and invalid percent escapes.
|
|
||
| private static Skills urlSource(String url, String sha256, boolean allowInsecureHttp) { | ||
| requireUrl(url, allowInsecureHttp); | ||
| if (sha256 == null || !sha256.matches("[0-9a-fA-F]{64}")) { |
There was a problem hiding this comment.
nit: this accepts either case and URLSkillRepository.java:90 lowercases before comparing, which matches what skills.md:144 promises ("lowercase or uppercase SHA-256 digest"). Every digest literal in the suite is lowercase though ("a".repeat(64) and friends), so nothing walks the uppercase path.
Would flipping one existing digest literal to uppercase cover it?
|
|
||
| @pytest.mark.parametrize( | ||
| "url", | ||
| ["https://exa mple.com/x.zip", "https://example.com/%invalid"], |
There was a problem hiding this comment.
nit: both of these cases hit the two new regexes at skills.py:189, so the except ValueError just above at skills.py:186-188 still has nothing exercising it. That branch is the only thing handling a URL urlparse itself refuses: urlparse("https://[::1/x.zip") raises Invalid IPv6 URL and neither regex matches that string, while URI.create rejects the same string as Expected closing bracket for IPv6 address.
Adding it to this list would cover it. Nothing asserts Invalid skill URL: on the Java side either (Skills.java:165-167), if you want one per language like last time.
Purpose of change
Closes #1003.
Remote skill archives are trusted instructions and code: their
SKILL.mdcontent is consumed by the agent, and bundled scripts can become executable through the built-in tools. Until now,Skills.fromUrl(...)/Skills.from_url(...)accepted plain HTTP and extracted whatever arrived, without verifying an operator-provided integrity expectation.This change makes URL skill sources secure by default and adds optional integrity verification across the Java, Python, and YAML surfaces.
allow_insecure_httpYAML option.url_sourceslist while preserving the existingurlsshorthand for unpinned HTTPS sources.Existing direct and same-protocol-redirecting HTTPS sources continue to download successfully. When a same-protocol redirect changes the effective URL, both runtimes now emit a warning with sensitive URL components removed. Existing plain HTTP sources must opt in explicitly.
Tests
./tools/ut.sh -j) passed under JDK 17 across all 32 reactor modules.git diff --checkpassed.API
Yes. The Java and Python skill factories reject plain HTTP by default and provide aligned methods for SHA-256 pinning and explicit HTTP opt-in:
fromUrlWithSha256,fromUrlUnsafe, andfromUrlUnsafeWithSha256.from_url_with_sha256,from_url_unsafe, andfrom_url_unsafe_with_sha256.YAML provides a structured
url_sourceslist whose entries accepturl, optionalsha256, and optionalallow_insecure_http. The existingurlslist remains available for ordinary HTTPS sources. Invalid URL and digest configuration is rejected during YAML loading, while runtime validation remains as defense in depth.Documentation
doc-neededdoc-not-neededdoc-includedWas this patch authored or co-authored using generative AI tooling?
Generated-by: OpenAI Codex (GPT-5)