Fix PyPI minimum-age fallback when cached metadata bypasses rewrite

This commit is contained in:
Reinier Criel 2026-04-17 09:37:40 -07:00
parent 782af8e789
commit 33c3bec43d
3 changed files with 41 additions and 0 deletions

View file

@ -6,6 +6,23 @@ export { parsePipMetadataUrl, isPipPackageInfoUrl } from "./parsePipPackageUrl.j
import { getPipMetadataContentType, logSuppressedVersion } from "./pipMetadataResponseUtils.js"; import { getPipMetadataContentType, logSuppressedVersion } from "./pipMetadataResponseUtils.js";
import { modifyPipJsonResponse } from "./modifyPipJsonResponse.js"; import { modifyPipJsonResponse } from "./modifyPipJsonResponse.js";
/**
* Strip conditional GET headers so PyPI always returns a full 200 response
* with a body we can rewrite. Without this, pip sends If-None-Match /
* If-Modified-Since, PyPI responds 304 Not Modified (empty body), and
* safe-chain cannot rewrite it leaving pip with a cached index that still
* lists too-young versions. Those versions are then blocked at direct-download
* time with a hard 403, preventing dependency resolution from completing.
*
* @param {NodeJS.Dict<string | string[]>} headers
* @returns {NodeJS.Dict<string | string[]>}
*/
export function modifyPipInfoRequestHeaders(headers) {
delete headers["if-none-match"];
delete headers["if-modified-since"];
return headers;
}
// Match simple-index anchor tags and capture their href so we can suppress // Match simple-index anchor tags and capture their href so we can suppress
// individual distribution links from PyPI HTML metadata responses. // individual distribution links from PyPI HTML metadata responses.
const HTML_ANCHOR_HREF_RE = const HTML_ANCHOR_HREF_RE =

View file

@ -9,6 +9,7 @@ import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.
import { interceptRequests } from "../interceptorBuilder.js"; import { interceptRequests } from "../interceptorBuilder.js";
import { isExcludedFromMinimumPackageAge } from "../minimumPackageAgeExclusions.js"; import { isExcludedFromMinimumPackageAge } from "../minimumPackageAgeExclusions.js";
import { import {
modifyPipInfoRequestHeaders,
modifyPipInfoResponse, modifyPipInfoResponse,
parsePipMetadataUrl, parsePipMetadataUrl,
} from "./modifyPipInfo.js"; } from "./modifyPipInfo.js";
@ -61,6 +62,7 @@ function createPipRequestHandler(registry) {
!isExcludedFromMinimumPackageAge(metadataPackageName) !isExcludedFromMinimumPackageAge(metadataPackageName)
) { ) {
const newPackagesDatabase = await openNewPackagesDatabase(); const newPackagesDatabase = await openNewPackagesDatabase();
reqContext.modifyRequestHeaders(modifyPipInfoRequestHeaders);
reqContext.modifyBody((body, headers) => reqContext.modifyBody((body, headers) =>
modifyPipInfoResponse( modifyPipInfoResponse(
body, body,

View file

@ -129,6 +129,28 @@ describe("pipInterceptor minimum package age", async () => {
newlyReleasedPackageResponse = false; newlyReleasedPackageResponse = false;
}); });
it("strips If-None-Match and If-Modified-Since from metadata requests to prevent 304 cache bypass", async () => {
const url = "https://pypi.org/simple/foo-bar/";
newlyReleasedPackageResponse = true;
const interceptor = pipInterceptorForUrl(url);
const result = await interceptor.handleRequest(url);
const headers = {
"if-none-match": '"some-etag"',
"if-modified-since": "Thu, 01 Jan 2026 00:00:00 GMT",
accept: "*/*",
};
result.modifyRequestHeaders(headers);
assert.equal(headers["if-none-match"], undefined, "If-None-Match must be stripped");
assert.equal(headers["if-modified-since"], undefined, "If-Modified-Since must be stripped");
assert.equal(headers.accept, "*/*", "unrelated headers must be preserved");
newlyReleasedPackageResponse = false;
});
it("should not block newly released package downloads when a dot-name package matches a hyphen exclusion", async () => { it("should not block newly released package downloads when a dot-name package matches a hyphen exclusion", async () => {
const url = const url =
"https://files.pythonhosted.org/packages/xx/yy/foo.bar-2.0.0.tar.gz"; "https://files.pythonhosted.org/packages/xx/yy/foo.bar-2.0.0.tar.gz";