Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 118 additions & 19 deletions internal/handler/npm.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ import (
)

const (
npmUpstream = "https://registry.npmjs.org"
npmAbbreviatedCT = "application/vnd.npm.install-v1+json"
scopedParts = 2 // scope + name in scoped packages
npmUpstream = "https://registry.npmjs.org"
npmAbbreviatedCT = "application/vnd.npm.install-v1+json"
npmTarballShasumParam = "git-pkgs-shasum"
scopedParts = 2 // scope + name in scoped packages
)

// NPMHandler handles npm registry protocol requests.
Expand Down Expand Up @@ -199,13 +200,13 @@ func (h *NPMHandler) rewriteTarballURLs(versions map[string]any, packageName str
continue
}

filename := tarball
if idx := strings.LastIndex(tarball, "/"); idx >= 0 {
filename = tarball[idx+1:]
}
filename, useShasum := h.proxyTarballFilename(packageName, version, tarball, dist)

escapedName := url.PathEscape(packageName)
newTarball := fmt.Sprintf("%s/npm/%s/-/%s", h.proxyURL, escapedName, filename)
if useShasum != "" {
newTarball += "?" + url.Values{npmTarballShasumParam: []string{useShasum}}.Encode()
}
dist["tarball"] = newTarball

h.proxy.Logger.Debug("rewrote tarball URL",
Expand All @@ -214,6 +215,35 @@ func (h *NPMHandler) rewriteTarballURLs(versions map[string]any, packageName str
}
}

func (h *NPMHandler) proxyTarballFilename(packageName, version, tarball string, dist map[string]any) (string, string) {
filename := tarball
if idx := strings.LastIndex(tarball, "/"); idx >= 0 {
filename = tarball[idx+1:]
}
if h.extractVersionFromFilename(packageName, filename) != "" {
return filename, ""
}

shasum, _ := dist["shasum"].(string)
if shasum == "" {
return filename, ""
}

return npmTarballFilename(packageName, version), shasum
}

func npmTarballFilename(packageName, version string) string {
return npmPackageShortName(packageName) + "-" + version + ".tgz"
}

func npmPackageShortName(packageName string) string {
parts := strings.SplitN(packageName, "/", scopedParts)
if len(parts) == scopedParts {
return parts[1]
}
return packageName
}

// findNewestVersion returns the version string with the most recent timestamp
// from the remaining versions, using the time map.
func (h *NPMHandler) findNewestVersion(versions map[string]any, timeMap map[string]any) string {
Expand Down Expand Up @@ -265,12 +295,12 @@ func (h *NPMHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
h.proxy.Logger.Info("npm download request",
"package", packageName, "version", version, "filename", filename)

downloadURL := fmt.Sprintf(
"%s/%s/-/%s",
h.upstreamURL,
escapeNPMDownloadPackage(packageName),
url.PathEscape(filename),
)
downloadURL, err := h.downloadURL(r, packageName, version, filename)
if err != nil {
h.proxy.Logger.Error("failed to resolve npm tarball URL", "error", err)
JSONError(w, http.StatusBadRequest, "invalid tarball request")
return
}
result, err := h.proxy.GetOrFetchArtifactFromURL(
r.Context(), "npm", packageName, version, filename, downloadURL,
)
Expand All @@ -287,6 +317,79 @@ func (h *NPMHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
ServeArtifact(w, result)
}

func (h *NPMHandler) downloadURL(r *http.Request, packageName, version, filename string) (string, error) {
shasum := r.URL.Query().Get(npmTarballShasumParam)
if shasum != "" {
return h.tarballURLForShasum(r, packageName, version, shasum)
}

return fmt.Sprintf(
"%s/%s/-/%s",
h.upstreamURL,
escapeNPMDownloadPackage(packageName),
url.PathEscape(filename),
), nil
}

func (h *NPMHandler) tarballURLForShasum(r *http.Request, packageName, version, shasum string) (string, error) {
metadataURL := fmt.Sprintf("%s/%s", h.upstreamURL, url.PathEscape(packageName))
body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "npm", packageName, metadataURL, npmAbbreviatedCT)
if err != nil {
return "", fmt.Errorf("fetching npm metadata: %w", err)
}

var metadata map[string]any
if err := json.Unmarshal(body, &metadata); err != nil {
return "", fmt.Errorf("parsing npm metadata: %w", err)
}

versions, ok := metadata["versions"].(map[string]any)
if !ok {
return "", errors.New("npm metadata has no versions")
}
vdata, ok := versions[version].(map[string]any)
if !ok {
return "", fmt.Errorf("npm metadata has no version %q", version)
}
dist, ok := vdata["dist"].(map[string]any)
if !ok {
return "", fmt.Errorf("npm metadata version %q has no dist", version)
}
if actualShasum, _ := dist["shasum"].(string); actualShasum != shasum {
return "", errors.New("npm tarball checksum does not match metadata")
}
tarball, ok := dist["tarball"].(string)
if !ok {
return "", fmt.Errorf("npm metadata version %q has no tarball", version)
}

return h.validateUpstreamTarballURL(tarball)
}

func (h *NPMHandler) validateUpstreamTarballURL(tarball string) (string, error) {
tarballURL, err := url.Parse(tarball)
if err != nil {
return "", fmt.Errorf("parsing tarball URL: %w", err)
}
upstreamURL, err := url.Parse(h.upstreamURL)
if err != nil {
return "", fmt.Errorf("parsing upstream URL: %w", err)
}
if tarballURL.User != nil || tarballURL.Scheme != upstreamURL.Scheme ||
!strings.EqualFold(tarballURL.Host, upstreamURL.Host) {
return "", errors.New("npm tarball URL does not match upstream registry")
}

basePath := strings.TrimSuffix(upstreamURL.Path, "/")
if basePath != "" && basePath != "/" {
if tarballURL.Path != basePath && !strings.HasPrefix(tarballURL.Path, basePath+"/") {
return "", errors.New("npm tarball URL is outside upstream base path")
}
}

return tarballURL.String(), nil
}

func escapeNPMDownloadPackage(packageName string) string {
scope, name, scoped := strings.Cut(packageName, "/")
if scoped && strings.HasPrefix(scope, "@") && len(scope) > 1 && name != "" && !strings.Contains(name, "/") {
Expand Down Expand Up @@ -345,12 +448,8 @@ func (h *NPMHandler) extractVersionFromFilename(packageName, filename string) st
}
base := strings.TrimSuffix(filename, ".tgz")

// For scoped packages, the filename uses the short name
shortName := packageName
if strings.Contains(packageName, "/") {
parts := strings.SplitN(packageName, "/", scopedParts)
shortName = parts[1]
}
// For scoped packages, the filename uses the short name.
shortName := npmPackageShortName(packageName)

// Expected format: {shortName}-{version}
prefix := shortName + "-"
Expand Down
138 changes: 138 additions & 0 deletions internal/handler/npm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,144 @@ func TestNPMRewriteMetadataScopedPackage(t *testing.T) {
}
}

func TestNPMRewriteMetadataGitHubPackagesTarball(t *testing.T) {
h := &NPMHandler{
proxy: testProxy(),
proxyURL: "http://localhost:8080",
}

input := `{
"name": "@example/private-package",
"versions": {
"1.0.0": {
"dist": {
"shasum": "e053d091c6ae91793f6333f5fe0a55633cf3c584",
"tarball": "https://npm.pkg.github.com/download/@example/private-package/1.0.0/e053d091c6ae91793f6333f5fe0a55633cf3c584"
}
}
}
}`

output, err := h.rewriteMetadata("@example/private-package", []byte(input))
if err != nil {
t.Fatalf("rewriteMetadata failed: %v", err)
}

var result map[string]any
if err := json.Unmarshal(output, &result); err != nil {
t.Fatalf("failed to parse output: %v", err)
}

versions := result["versions"].(map[string]any)
v := versions[testVersion100].(map[string]any)
dist := v["dist"].(map[string]any)
tarball := dist["tarball"].(string)

expected := "http://localhost:8080/npm/@example%2Fprivate-package/-/private-package-1.0.0.tgz?git-pkgs-shasum=e053d091c6ae91793f6333f5fe0a55633cf3c584"
if tarball != expected {
t.Errorf("tarball = %q, want %q", tarball, expected)
}
}

func TestNPMHandlerDownloadsGitHubPackagesTarball(t *testing.T) {
const shasum = "e053d091c6ae91793f6333f5fe0a55633cf3c584"
const tarballPath = "/download/@example/private-package/1.0.0/" + shasum

var upstream *httptest.Server
upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/@example/private-package" {
t.Errorf("metadata path = %q, want scoped package path", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", contentTypeJSON)
_, _ = io.WriteString(w, `{"versions":{"1.0.0":{"dist":{"shasum":"`+shasum+`","tarball":"`+upstream.URL+tarballPath+`"}}}}`)
}))
defer upstream.Close()

proxy, _, _, artifactFetcher := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
artifactFetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("package")),
ContentType: "application/gzip",
}
h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)

req := httptest.NewRequest(
http.MethodGet,
"/@example/private-package/-/private-package-1.0.0.tgz?git-pkgs-shasum="+shasum,
nil,
)
w := httptest.NewRecorder()
h.Routes().ServeHTTP(w, req)

if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
}
if artifactFetcher.fetchedURL != upstream.URL+tarballPath {
t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, upstream.URL+tarballPath)
}
}

func TestNPMHandlerRejectsMismatchedGitHubPackagesChecksum(t *testing.T) {
const actualShasum = "e053d091c6ae91793f6333f5fe0a55633cf3c584"

var upstream *httptest.Server
upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", contentTypeJSON)
_, _ = io.WriteString(w, `{"versions":{"1.0.0":{"dist":{"shasum":"`+actualShasum+`","tarball":"`+upstream.URL+`/download/pkg/1.0.0/`+actualShasum+`"}}}}`)
}))
defer upstream.Close()

proxy, _, _, artifactFetcher := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)

req := httptest.NewRequest(
http.MethodGet,
"/pkg/-/pkg-1.0.0.tgz?git-pkgs-shasum=incorrect",
nil,
)
w := httptest.NewRecorder()
h.Routes().ServeHTTP(w, req)

if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusBadRequest, w.Body.String())
}
if artifactFetcher.fetchedURL != "" {
t.Errorf("artifact fetcher should not be called, fetched URL = %q", artifactFetcher.fetchedURL)
}
}

func TestNPMHandlerRejectsTarballFromDifferentHost(t *testing.T) {
const shasum = "e053d091c6ae91793f6333f5fe0a55633cf3c584"

upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", contentTypeJSON)
_, _ = io.WriteString(w, `{"versions":{"1.0.0":{"dist":{"shasum":"`+shasum+`","tarball":"https://example.invalid/package.tgz"}}}}`)
}))
defer upstream.Close()

proxy, _, _, artifactFetcher := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)

req := httptest.NewRequest(
http.MethodGet,
"/pkg/-/pkg-1.0.0.tgz?git-pkgs-shasum="+shasum,
nil,
)
w := httptest.NewRecorder()
h.Routes().ServeHTTP(w, req)

if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusBadRequest, w.Body.String())
}
if artifactFetcher.fetchedURL != "" {
t.Errorf("artifact fetcher should not be called, fetched URL = %q", artifactFetcher.fetchedURL)
}
}

func TestNPMHandlerMetadataProxy(t *testing.T) {
// Create a mock upstream server
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down