Conversation
The metadata served here is filtered, so its checksums have to be recomputed over the filtered document — the upstream's value describes a different document. Only sha1 and md5 were. A request for .sha256 or .sha512 fell through to the download route instead: at four path segments /g/a/maven-metadata.xml.sha512 looks like an artifact, so 'a' was parsed as the version, deps.dev had no such release, and the request 404'd. Maven 3.9 and Gradle both use the SHA-2 checksums. isMavenMetadataPath now covers all four, and isMavenDownloadPath no longer claims metadata paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Maven repository may answer maven-metadata.xml with a redirect rather than the document: repo.scala-sbt.org/scalasbt/maven-releases 302s to Central and maven.google.com 301s to dl.google.com. Passing that redirect to the client sent it straight to the upstream, so the age and malicious filters were skipped entirely — the proxy returned 302 and the client fetched unfiltered metadata. Follow the hops server-side (301/302/303/307/308) and filter the resolved document. A relative Location resolves against the request URL, as HTTP requires. Following is capped at three hops, and a response that is still a redirect at the cap is answered with 502 rather than returned — returning it would have the base class forward it and reopen the same bypass. GradlePluginsRegistryProxy already did this for the portal's 303s; that override is now redundant and removed, so every Maven-layout registry gets the same handling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A -SNAPSHOT version has a second maven-metadata.xml inside its version directory describing one timestamped build. It uses <snapshotVersions> rather than <versions>, so it was taken for group-level metadata and passed through untouched — timestamped snapshot builds escaped the cooldown entirely. The document carries the build's own time in <lastUpdated> (or <snapshot><timestamp>), so no external lookup is needed. It describes a single build, so it is served whole when that build is past the cooldown and 404'd when it is not, or when no timestamp can be read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
deps.dev only indexes Maven Central, so the Maven proxy could only enforce a
cooldown for Central. Pointing --maven-upstream anywhere else made deps.dev
return nothing, which left every version unknown and served empty metadata.
Add a Last-Modified timestamp source that HEADs a version's POM, and a
--maven-timestamp-source flag to select it. A -SNAPSHOT version has no
{artifact}-{version}.pom, so its version directory's maven-metadata.xml,
rewritten on every build, stands in as the probe.
Add a repeatable --maven-repo <name>=<url> that mounts an extra Maven-layout
repository at its own top-level path, reusing MavenRegistryProxy verbatim.
These default to last-modified, since they are by definition not Central.
maven-metadata.xml carries no per-version timestamps, so each version needs a
probe — but <lastUpdated> records when the document was last rewritten, i.e.
when its newest version appeared, so when that already predates the cutoff
nothing needs probing at all: it is an upper bound on every listed version, and
the common case costs no extra requests. Otherwise every version is probed,
bounded to PROBE_CONCURRENCY at a time and cached, so a metadata request does
not re-probe what the last one resolved.
Every version, not a walk that stops early: <versions> is not in publication
order. org.apache.logging.log4j:log4j-core ends its list at a 2024 prerelease
while its newest release is from 2026, and jackson-databind has 36 adjacent
pairs whose publish dates run backwards, because a maintenance release lands
after the next minor's first prerelease. A security patch to a maintenance
branch is out of order by construction, so the releases most worth delaying are
the ones a walk would let through first.
The cache is bounded rather than a plain Map: a long-running proxy sees an
unbounded number of artifact URLs, so TtlCache drops an entry when it is read
after expiry and evicts the oldest writes past a cap. Expiry is short because a
repository can rewrite an mtime on re-sync, and a stale entry would keep serving
a version whose timestamp has moved into the cooldown window. Its fields are
assigned in the constructor body rather than declared as parameter properties:
tengen runs straight from source (npm start is `node src/tengen.ts serve`) and
Node's type stripping transforms nothing, so a parameter property is
ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX at startup.
A version whose timestamp cannot be read is excluded rather than admitted. Ties
when choosing the filtered <latest> are broken by the document's own ordering,
where Maven puts the newest last — equal timestamps are normal with this source,
since a re-synced repository stamps every file with the same mtime.
The two timestamp sources are exclusive: with deps-dev a missing record still
leaves the version blocked, so Central's existing fail-closed behaviour is
unchanged.
Last-Modified is the file's mtime on the upstream, not a publication date,
and the two can be far apart — Artifactory rewrites it on re-sync, and files
of the same version need not agree (one observed version has an ivy.xml from
2018 next to a jar from 2021). It is nonetheless the only per-version
timestamp a plain Maven repository exposes, and monotonic enough for a
cooldown measured in days.
Verified against repo1.maven.org with --delay-days 180 and log4j-core: 2.25.5,
2.26.0 and 2.26.1 are inside the window and all three are excluded (78 versions
become 75, <latest> moves to 2.25.4). Probing all 78 takes 1.4s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sbt's built-in resolver set includes three Ivy-layout repositories
(sbt-plugin-releases, ivy-snapshots, typesafe ivy-releases), so a proxy that
only speaks Maven cannot cover sbt.
Add IvyRegistryProxy and a repeatable --ivy-repo <name>=<url> that mounts one
at its own top-level path. Ivy lays artifacts out as
{org}/{module}(/scala_{v})(/sbt_{v})/{revision}/{type}s/{artifact}.{ext}, and
every request for a file under a revision is gated on that revision's age, read
from the Last-Modified of its ivys/ivy.xml. Redirects are followed:
repo.scala-sbt.org and repo.typesafe.com both 302 to the Artifactory instance
holding the file.
The directory index is passed through rather than filtered. Ivy has no document
listing a module's revisions — a client discovers them by reading the
repository's HTML index, which is presentation rather than protocol: the format
differs between Artifactory, Nexus 2, Nexus 3 and Clojars, and dates are absent
entirely on Clojars. Filtering it would mean scraping each of them and tracking
their changes, so the proxy does not depend on it at all.
The cooldown still holds for a dynamic revision, by refusing the artifact
rather than by steering the choice. Measured with Coursier 2.1.25 against
sbt-plugin-releases at --delay-days 4000: the listing passes through, Coursier
picks one revision, requests its ivy.xml, gets 404 and stops — it does not try
the next one, so a revision inside the window cannot be taken silently. It does
not fall back to an older allowed revision either, so a build on
latest.integration breaks until the newest revision ages out. Which revision
gets picked is the resolver's own version ordering, not the listing's: Coursier
asked for 1.3.4+151-7c324c7c while the listing ended at 1.3.4+160-681434ff.
Malicious and allowlist lookups use the shared maven ecosystem, since OSV tracks
JVM artifacts there regardless of repository layout.
Verified against repo.scala-sbt.org/scalasbt/sbt-plugin-releases: listings pass
through at any --delay-days, while the same download is served at 7 and refused
at 4000.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gating downloads alone leaves a dynamic revision resolving to failure: the
resolver reads the module's directory listing, picks the newest revision it
sees, and stops at the 404 — it neither retries nor falls back, so a build on
latest.integration breaks until that revision ages out.
Answer the listing instead, with only the allowed revisions in it, so the
resolver never sees one it cannot have. Opt in per repository with
--ivy-repo <name>=<url>,index=artifactory.
The revision names come from Artifactory's storage API as JSON
(api/storage/{repo}/{path} -> children[{uri, folder}]); nothing reads the
upstream's HTML, and the only HTML involved is the listing this proxy writes,
because that is the shape a resolver expects. It is written with the escape-html
dependency the PyPI proxy already uses, and revision names go in otherwise
verbatim: percent-encoding them looks more correct and is not — Artifactory
writes href="1.0.0-RC1+4-c5e24b66/" literally, and Coursier 2.1.25 reads the
href as written, finding no revisions at all when given %2B.
Each revision is dated by the same ivys/ivy.xml HEAD the download gate uses, so
the listing and the gate cannot disagree. The folder's own lastModified is
deliberately not used: Artifactory rewrites it on re-sync, and one revision of
ch.epfl.scala:sbt-bloop has a folder stamped 2021-04-14 holding an ivy.xml
stamped 2019-11-06 — dating the listing that way offered nothing while the gate
served the newest revision. Probes are bounded at 8 in flight and share the
Maven side's bounded cache.
Only a directory that actually holds revisions is filtered. A path cannot say
which: /ch.epfl.scala/sbt-bloop/ holds scala_2.10 and scala_2.12, and .../1.5.6/
holds ivys and jars, so filtering either as if its children were revisions would
hide them all. The children decide — one that dates is a revision. When none
date, the storage API is asked whether any child holds an artifact-type
directory: if it does, the children are revisions whose ivy.xml cannot be read
and the listing is refused with 502 rather than passed through; if it does not,
the directory holds no revisions to hide and is passed through untouched. A
directory whose revisions all sit inside the cooldown is a different case — they
date fine, so the answer is a listing with nothing in it.
The endpoint is resolved and verified at startup, not per request, and nothing
about the URL's shape is assumed. Artifactory is commonly served under an
/artifactory context path, but an on-prem deployment behind a reverse proxy can
sit anywhere — including the host root — so the split between the API base and
the repository key is not readable off the URL. The configured URL's redirects
are followed (repo.scala-sbt.org and repo.typesafe.com both 302 to
scala.jfrog.io/artifactory/{repo}), then every split of the resulting path is
tried against api/storage and the one that answers with a readable FolderInfo
wins: for https://scala.jfrog.io/artifactory/sbt-plugin-releases that rejects
apiBase=https://scala.jfrog.io, repo=artifactory (404) and accepts
apiBase=.../artifactory, repo=sbt-plugin-releases, while for a proxy publishing
a repository at https://repo.example.com/ivy-releases the first split is already
the right one. api/system/version is then read and reported under its repository
in the startup banner.
Failure is fatal — a repository configured to filter its listings either can or
tengen does not start, and the error names every split it tried. The option
carries neither a version nor a URL pattern because the fields used have been in
FolderInfo since Artifactory 2.2.1 and still are in 7.x, and neither could tell
you whether a given deployment answers, which is what the probe checks. At
runtime an unreadable API yields 502, never an unfiltered list.
--ivy-repo now takes <name>=<url>[,index=artifactory], and parseNamedRepos
splits those options off before validating the URL, so validation and the
trailing-slash trim see the URL itself and an error message quotes only that.
Verified against repo.scala-sbt.org/scalasbt/sbt-plugin-releases (Artifactory
7.171.0) at --delay-days 3000: .../sbt_1.0/ lists 177 of the upstream's 381
revisions, every listed revision downloads (307) and every excluded one is
refused (404), while /ch.epfl.scala/, /ch.epfl.scala/sbt-bloop/, .../scala_2.12/
and a revision's own directory all pass through. Coursier 2.1.25 resolved
latest.integration to 1.0.0-RC1+4-c5e24b66 — the newest listed revision — and
fetched the jar. At --delay-days 7 all 381 are listed.
repo.typesafe.com/typesafe/ivy-releases and the direct
scala.jfrog.io/artifactory/sbt-plugin-releases both resolve too;
repo.clojars.org/some/path is fatal at startup.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kijuky
marked this pull request as ready for review
September 26, 2026 16:55
kijuky
marked this pull request as draft
September 26, 2026 16:56
kijuky
marked this pull request as ready for review
September 26, 2026 16:56
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes
maven-metadata.xml.sha256and.sha512are recomputed over the filtered document, as sha1 and md5 already were.maven-metadata.xmlare followed server-side (three hops, then 502) and the resolved document is filtered. ReplacesGradlePluginsRegistryProxy.fetchUpstreamXml, which handled 303 only.maven-metadata.xmlis filtered on its<lastUpdated>: served whole past the cooldown, 404'd inside it.--maven-repo <name>=<url>(repeatable)Mounts a Maven-layout repository at
/<name>, reusingMavenRegistryProxy.Version dates come from the upstream's
Last-Modifiedinstead of deps.dev, which indexes Central only.--maven-timestamp-sourceselects the source for the built-in/mavenroute, and defaults todeps-dev.<lastUpdated>bounds every version in the document, so one older than the cutoff needs no probing.-SNAPSHOTis probed through its version directory'smaven-metadata.xml.<versions>is deploy order rather than publication order.--ivy-repo <name>=<url>[,index=artifactory](repeatable)Mounts an Ivy-layout repository at
/<name>:Every file under a revision is gated on that revision's
ivys/ivy.xmlLast-Modified. Malicious and allowlist lookups use the sharedmavenecosystem.index=artifactoryadditionally filters revision listings, which a dynamic revision (latest.integration,1.2.+) resolves from. The listing is generated from Artifactory's storage API (api/storage/{repo}/{path}).ivy.xmlHEAD the download gate uses, so the listing never offers a revision the gate refuses.api/storage, so a deployment at any path works. Failure is fatal.Usage
Restrictions
Last-Modifiedis mtime, not publication time, and repositories rewrite it on re-sync.indexacceptsartifactoryonly.index, a dynamic revision inside the cooldown fails to resolve rather than falling back.-SNAPSHOTis unavailable until its latest build ages out; use the allowlist.