diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js index 9ef4328..ef0ab18 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/modifyPipInfo.js @@ -6,6 +6,23 @@ export { parsePipMetadataUrl, isPipPackageInfoUrl } from "./parsePipPackageUrl.j import { getPipMetadataContentType, logSuppressedVersion } from "./pipMetadataResponseUtils.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} headers + * @returns {NodeJS.Dict} + */ +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 // individual distribution links from PyPI HTML metadata responses. const HTML_ANCHOR_HREF_RE = diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js index 51e6f0d..86d84eb 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.js @@ -9,6 +9,7 @@ import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache. import { interceptRequests } from "../interceptorBuilder.js"; import { isExcludedFromMinimumPackageAge } from "../minimumPackageAgeExclusions.js"; import { + modifyPipInfoRequestHeaders, modifyPipInfoResponse, parsePipMetadataUrl, } from "./modifyPipInfo.js"; @@ -61,6 +62,7 @@ function createPipRequestHandler(registry) { !isExcludedFromMinimumPackageAge(metadataPackageName) ) { const newPackagesDatabase = await openNewPackagesDatabase(); + reqContext.modifyRequestHeaders(modifyPipInfoRequestHeaders); reqContext.modifyBody((body, headers) => modifyPipInfoResponse( body, diff --git a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js index 6bbd904..f311df7 100644 --- a/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js +++ b/packages/safe-chain/src/registryProxy/interceptors/pip/pipInterceptor.minPackageAge.spec.js @@ -129,6 +129,28 @@ describe("pipInterceptor minimum package age", async () => { 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 () => { const url = "https://files.pythonhosted.org/packages/xx/yy/foo.bar-2.0.0.tar.gz";