fix: harden local proxy and import boundaries

This commit is contained in:
Andy Dixon
2026-06-16 17:09:20 +01:00
parent 739651d548
commit cb2fbf70eb
12 changed files with 1189 additions and 49 deletions
+68 -7
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strings"
@@ -22,6 +23,7 @@ import (
const (
maxGitHubJSONBodyBytes = 4 << 20
maxGitHubErrorBodyBytes = 64 << 10
maxGitHubArchiveBytes = int64(2 << 30)
)
// Client wraps the GitHub REST API for repository listing.
@@ -190,9 +192,16 @@ func (c *Client) fetchRepos(ctx context.Context, progress func(done int, total i
// FetchLatestCommit returns the committer name and commit time for a repo/ref.
func (c *Client) FetchLatestCommit(ctx context.Context, fullName string, ref string) (string, string, error) {
url := fmt.Sprintf("%s/repos/%s/commits/%s", c.baseURL, fullName, ref)
if ref == "" {
url = fmt.Sprintf("%s/repos/%s/commits/HEAD", c.baseURL, fullName)
ref = "HEAD"
}
owner, repo, err := splitRepoFullName(fullName)
if err != nil {
return "", "", err
}
url, err := c.endpointURL("repos", owner, repo, "commits", ref)
if err != nil {
return "", "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
@@ -252,12 +261,17 @@ func (c *Client) FetchLatestCommit(ctx context.Context, fullName string, ref str
// DownloadRepoArchive downloads a repository zip archive to the given path.
func (c *Client) DownloadRepoArchive(ctx context.Context, fullName string, ref string, destPath string) error {
if fullName == "" {
return fmt.Errorf("repository name cannot be empty")
owner, repo, err := splitRepoFullName(fullName)
if err != nil {
return err
}
url := fmt.Sprintf("%s/repos/%s/zipball", c.baseURL, fullName)
segments := []string{"repos", owner, repo, "zipball"}
if ref != "" {
url = url + "/" + ref
segments = append(segments, ref)
}
url, err := c.endpointURL(segments...)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
@@ -280,6 +294,9 @@ func (c *Client) DownloadRepoArchive(ctx context.Context, fullName string, ref s
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, readGitHubErrorBody(resp.Body))
}
if resp.ContentLength > maxGitHubArchiveBytes {
return fmt.Errorf("archive is too large: %d bytes exceeds %d bytes", resp.ContentLength, maxGitHubArchiveBytes)
}
file, err := os.Create(destPath)
if err != nil {
@@ -287,7 +304,7 @@ func (c *Client) DownloadRepoArchive(ctx context.Context, fullName string, ref s
}
defer file.Close()
if _, err := io.Copy(file, resp.Body); err != nil {
if _, err := copyLimitedArchive(file, resp.Body, maxGitHubArchiveBytes); err != nil {
return fmt.Errorf("writing archive: %w", err)
}
return nil
@@ -345,3 +362,47 @@ func readGitHubErrorBody(r io.Reader) string {
}
return strings.TrimSpace(string(body))
}
func splitRepoFullName(fullName string) (string, string, error) {
parts := strings.Split(strings.TrimSpace(fullName), "/")
if len(parts) != 2 {
return "", "", fmt.Errorf("repository name must be in owner/repo format")
}
owner := strings.TrimSpace(parts[0])
repo := strings.TrimSpace(parts[1])
if owner == "" || repo == "" {
return "", "", fmt.Errorf("repository name must be in owner/repo format")
}
return owner, repo, nil
}
func (c *Client) endpointURL(segments ...string) (string, error) {
u, err := url.Parse(c.baseURL)
if err != nil {
return "", fmt.Errorf("invalid GitHub base URL: %w", err)
}
plainPath := strings.TrimRight(u.Path, "/")
escapedPath := strings.TrimRight(u.EscapedPath(), "/")
for _, segment := range segments {
if segment == "" {
return "", fmt.Errorf("empty GitHub API path segment")
}
plainPath += "/" + segment
escapedPath += "/" + url.PathEscape(segment)
}
u.Path = plainPath
u.RawPath = escapedPath
return u.String(), nil
}
func copyLimitedArchive(dst io.Writer, src io.Reader, maxBytes int64) (int64, error) {
limited := &io.LimitedReader{R: src, N: maxBytes + 1}
n, err := io.Copy(dst, limited)
if err != nil {
return n, err
}
if n > maxBytes {
return n, fmt.Errorf("archive exceeds %d bytes", maxBytes)
}
return n, nil
}
@@ -1,6 +1,8 @@
package githubclient
import (
"bytes"
"io"
"strings"
"testing"
)
@@ -24,3 +26,50 @@ func TestReadLimitedGitHubBodyAllowsResponseAtLimit(t *testing.T) {
t.Fatalf("body = %q, want abc", string(got))
}
}
func TestEndpointURLEscapesRepoPathSegments(t *testing.T) {
client := New("https://ghe.example.com/api/v3", "")
got, err := client.endpointURL("repos", "owner", "repo", "commits", "feature/a b")
if err != nil {
t.Fatalf("endpointURL() error = %v", err)
}
want := "https://ghe.example.com/api/v3/repos/owner/repo/commits/feature%2Fa%20b"
if got != want {
t.Fatalf("endpointURL() = %q, want %q", got, want)
}
}
func TestSplitRepoFullNameRejectsExtraSegments(t *testing.T) {
if _, _, err := splitRepoFullName("owner/repo/../../user"); err == nil {
t.Fatal("expected malformed repository name to be rejected")
}
}
func TestCopyLimitedArchiveRejectsOversizedStream(t *testing.T) {
var dst bytes.Buffer
n, err := copyLimitedArchive(&dst, strings.NewReader("abcd"), 3)
if err == nil {
t.Fatal("expected oversized archive stream to be rejected")
}
if n != 4 {
t.Fatalf("copied = %d, want 4", n)
}
if !strings.Contains(err.Error(), "archive exceeds") {
t.Fatalf("error = %q, want archive exceeds", err.Error())
}
}
func TestCopyLimitedArchiveAllowsStreamAtLimit(t *testing.T) {
var dst bytes.Buffer
n, err := copyLimitedArchive(&dst, strings.NewReader("abc"), 3)
if err != nil {
t.Fatalf("copyLimitedArchive() error = %v", err)
}
if n != 3 {
t.Fatalf("copied = %d, want 3", n)
}
if got, err := io.ReadAll(&dst); err != nil || string(got) != "abc" {
t.Fatalf("dst = %q, err = %v; want abc", string(got), err)
}
}