Skip to content

Commit 0852f73

Browse files
authored
Authenticate Python downloads discovered from Simple API responses (#139)
1 parent 827e737 commit 0852f73

8 files changed

Lines changed: 895 additions & 18 deletions

internal/handlers/oidc_handling_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package handlers
33
import (
44
"bytes"
55
"fmt"
6+
"io"
67
"log"
78
"net/http"
89
"net/http/httptest"
@@ -1741,6 +1742,64 @@ func TestPythonOIDCSimpleSuffixStripping(t *testing.T) {
17411742
assertHasTokenAuth(t, reqB, "Bearer", "__token_B__", "feed-B request should use token B")
17421743
}
17431744

1745+
func TestPythonOIDCAuthenticatesDiscoveredDownloadPrefix(t *testing.T) {
1746+
httpmock.Activate()
1747+
defer httpmock.DeactivateAndReset()
1748+
1749+
tenantID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
1750+
clientID := "87654321-4321-4321-4321-210987654321"
1751+
tokenURL := "https://token.actions.example.com" //nolint:gosec // test URL
1752+
1753+
httpmock.RegisterResponder("GET", tokenURL,
1754+
httpmock.NewStringResponder(200, `{"count":1,"value":"sometoken"}`))
1755+
httpmock.RegisterResponder("POST", fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", tenantID),
1756+
httpmock.NewStringResponder(200, `{"access_token":"__oidc_token__","expires_in":3600,"token_type":"Bearer"}`))
1757+
1758+
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", tokenURL)
1759+
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "sometoken")
1760+
1761+
handler := NewPythonIndexHandler(config.Credentials{
1762+
config.Credential{
1763+
"type": "python_index",
1764+
"index-url": "https://pkgs.example.com/my-org/my-project/_packaging/my-feed/pypi/simple/",
1765+
"tenant-id": tenantID,
1766+
"client-id": clientID,
1767+
},
1768+
})
1769+
1770+
ctx := &goproxy.ProxyCtx{}
1771+
indexReq := httptest.NewRequest(
1772+
"GET",
1773+
"https://pkgs.example.com/my-org/my-project/_packaging/my-feed/pypi/simple/my-package/",
1774+
nil,
1775+
)
1776+
indexReq = handleRequestAndClose(handler, indexReq, ctx)
1777+
assertHasTokenAuth(t, indexReq, "Bearer", "__oidc_token__", "simple index request should use OIDC token")
1778+
1779+
indexResp := &http.Response{
1780+
StatusCode: http.StatusOK,
1781+
Header: http.Header{
1782+
"Content-Type": []string{"text/html"},
1783+
},
1784+
Body: io.NopCloser(strings.NewReader(`
1785+
<html><body>
1786+
<a href="https://pkgs.example.com/my-org/project-id/_packaging/feed-id/pypi/download/my-package/1.0.0/my-package-1.0.0.whl#sha256=abc">
1787+
my-package-1.0.0.whl
1788+
</a>
1789+
</body></html>
1790+
`)),
1791+
}
1792+
handler.HandleResponse(indexResp, ctx)
1793+
1794+
downloadReq := httptest.NewRequest(
1795+
"HEAD",
1796+
"https://pkgs.example.com/my-org/project-id/_packaging/feed-id/pypi/download/my-package/1.0.0/my-package-1.0.0.whl",
1797+
nil,
1798+
)
1799+
downloadReq = handleRequestAndClose(handler, downloadReq, &goproxy.ProxyCtx{})
1800+
assertHasTokenAuth(t, downloadReq, "Bearer", "__oidc_token__", "discovered download request should use OIDC token")
1801+
}
1802+
17441803
// TestNPMOIDCSameHostDifferentPaths verifies that two npm OIDC credentials on
17451804
// the same host with different URL paths do not collide — each request is
17461805
// authenticated with the credential whose path is the longest prefix match.

internal/handlers/python_index.go

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,11 @@ package handlers
33
import (
44
"net/http"
55
"regexp"
6-
"strings"
76

87
"github.com/elazarl/goproxy"
98

109
"github.com/dependabot/proxy/internal/config"
1110
"github.com/dependabot/proxy/internal/helpers"
12-
"github.com/dependabot/proxy/internal/logging"
1311
"github.com/dependabot/proxy/internal/oidc"
1412
)
1513

@@ -18,6 +16,7 @@ var simpleSuffixRe = regexp.MustCompile(`/\+?simple/?\z`)
1816
// PythonIndexHandler handles requests to Python indexes, adding auth.
1917
type PythonIndexHandler struct {
2018
credentials []pythonIndexCredentials
19+
downloadAuth *pythonIndexDownloadAuthStore
2120
oidcRegistry *oidc.OIDCRegistry
2221
}
2322

@@ -33,6 +32,7 @@ type pythonIndexCredentials struct {
3332
func NewPythonIndexHandler(creds config.Credentials) *PythonIndexHandler {
3433
handler := PythonIndexHandler{
3534
credentials: []pythonIndexCredentials{},
35+
downloadAuth: newPythonIndexDownloadAuthStore(),
3636
oidcRegistry: oidc.NewOIDCRegistry(),
3737
}
3838

@@ -87,8 +87,13 @@ func (h *PythonIndexHandler) HandleRequest(req *http.Request, ctx *goproxy.Proxy
8787
return req, nil
8888
}
8989

90+
if auth, ok := h.downloadAuth.authFor(req); ok && h.applyAuth(req, ctx, auth) {
91+
return req, nil
92+
}
93+
9094
// Try OIDC credentials first
91-
if h.oidcRegistry.TryAuth(req, ctx) {
95+
if credential := h.oidcRegistry.CredentialForRequest(req); h.oidcRegistry.TryAuthCredential(req, ctx, credential) {
96+
rememberPythonIndexResponseAuth(ctx, req.URL, pythonIndexAuth{oidc: credential})
9297
return req, nil
9398
}
9499

@@ -99,15 +104,9 @@ func (h *PythonIndexHandler) HandleRequest(req *http.Request, ctx *goproxy.Proxy
99104
continue
100105
}
101106

102-
logging.RequestLogf(ctx, "* authenticating python index request (host: %s)", req.URL.Hostname())
103-
104-
token := cred.token
105-
if token == "" && cred.password != "" {
106-
token = cred.username + ":" + cred.password
107-
}
108-
// ignore `found` because it's okay for the password to be an empty string
109-
username, password, _ := strings.Cut(token, ":")
110-
helpers.SetBasicAuthorization(req, username, password)
107+
auth := pythonIndexAuth{basic: cred, hasBasic: true}
108+
h.applyAuth(req, ctx, auth)
109+
rememberPythonIndexResponseAuth(ctx, req.URL, auth)
111110

112111
return req, nil
113112
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package handlers
2+
3+
import (
4+
"net/http"
5+
"net/url"
6+
"strings"
7+
"sync"
8+
9+
"github.com/elazarl/goproxy"
10+
11+
"github.com/dependabot/proxy/internal/ctxdata"
12+
"github.com/dependabot/proxy/internal/helpers"
13+
"github.com/dependabot/proxy/internal/logging"
14+
"github.com/dependabot/proxy/internal/oidc"
15+
)
16+
17+
const (
18+
pythonIndexResponseAuthKey = "python-index-response-auth"
19+
maxPythonIndexDownloadAuthEntries = 1024
20+
)
21+
22+
type pythonIndexAuth struct {
23+
oidc *oidc.OIDCCredential
24+
basic pythonIndexCredentials
25+
hasBasic bool
26+
}
27+
28+
type pythonIndexResponseAuth struct {
29+
auth pythonIndexAuth
30+
baseURL url.URL
31+
}
32+
33+
type pythonIndexDownloadAuthStore struct {
34+
mutex sync.RWMutex
35+
entries []pythonIndexDownloadAuthEntry
36+
}
37+
38+
type pythonIndexDownloadAuthEntry struct {
39+
prefix *url.URL
40+
auth pythonIndexAuth
41+
}
42+
43+
func newPythonIndexDownloadAuthStore() *pythonIndexDownloadAuthStore {
44+
return &pythonIndexDownloadAuthStore{}
45+
}
46+
47+
func (s *pythonIndexDownloadAuthStore) add(prefix *url.URL, auth pythonIndexAuth) {
48+
if prefix == nil {
49+
return
50+
}
51+
52+
normalized := normalizedPythonIndexDownloadURL(prefix)
53+
if normalized == nil {
54+
return
55+
}
56+
57+
s.mutex.Lock()
58+
defer s.mutex.Unlock()
59+
60+
for i := range s.entries {
61+
if sameOrigin(s.entries[i].prefix, normalized) && s.entries[i].prefix.Path == normalized.Path {
62+
return
63+
}
64+
}
65+
66+
entry := pythonIndexDownloadAuthEntry{
67+
prefix: normalized,
68+
auth: auth,
69+
}
70+
if len(s.entries) >= maxPythonIndexDownloadAuthEntries {
71+
copy(s.entries, s.entries[1:])
72+
s.entries[len(s.entries)-1] = entry
73+
return
74+
}
75+
s.entries = append(s.entries, entry)
76+
}
77+
78+
func (s *pythonIndexDownloadAuthStore) authFor(req *http.Request) (pythonIndexAuth, bool) {
79+
s.mutex.RLock()
80+
defer s.mutex.RUnlock()
81+
82+
var matched pythonIndexAuth
83+
bestPathLen := -1
84+
for _, entry := range s.entries {
85+
if !sameOrigin(entry.prefix, req.URL) || !isPathPrefix(entry.prefix.Path, req.URL.Path) {
86+
continue
87+
}
88+
if len(entry.prefix.Path) > bestPathLen {
89+
matched = entry.auth
90+
bestPathLen = len(entry.prefix.Path)
91+
}
92+
}
93+
94+
if bestPathLen < 0 {
95+
return pythonIndexAuth{}, false
96+
}
97+
return matched, true
98+
}
99+
100+
func (h *PythonIndexHandler) applyAuth(req *http.Request, ctx *goproxy.ProxyCtx, auth pythonIndexAuth) bool {
101+
if auth.oidc != nil {
102+
return h.oidcRegistry.TryAuthCredential(req, ctx, auth.oidc)
103+
}
104+
if !auth.hasBasic {
105+
return false
106+
}
107+
108+
logging.RequestLogf(ctx, "* authenticating python index request (host: %s)", req.URL.Hostname())
109+
110+
token := auth.basic.token
111+
if token == "" && auth.basic.password != "" {
112+
token = auth.basic.username + ":" + auth.basic.password
113+
}
114+
// ignore `found` because it's okay for the password to be an empty string
115+
username, password, _ := strings.Cut(token, ":")
116+
helpers.SetBasicAuthorization(req, username, password)
117+
118+
return true
119+
}
120+
121+
func rememberPythonIndexResponseAuth(ctx *goproxy.ProxyCtx, baseURL *url.URL, auth pythonIndexAuth) {
122+
if ctx == nil || baseURL == nil {
123+
return
124+
}
125+
126+
ctxdata.SetValue(ctx, pythonIndexResponseAuthKey, pythonIndexResponseAuth{
127+
auth: auth,
128+
baseURL: *baseURL,
129+
})
130+
}
131+
132+
func pythonIndexResponseAuthFromContext(ctx *goproxy.ProxyCtx) (pythonIndexResponseAuth, bool) {
133+
if ctx == nil {
134+
return pythonIndexResponseAuth{}, false
135+
}
136+
137+
value, ok := ctxdata.GetValue(ctx, pythonIndexResponseAuthKey)
138+
if !ok {
139+
return pythonIndexResponseAuth{}, false
140+
}
141+
142+
responseAuth, ok := value.(pythonIndexResponseAuth)
143+
return responseAuth, ok
144+
}

0 commit comments

Comments
 (0)