Skip to content

Commit 83db3b5

Browse files
committed
using multiple routes in dependency proxy
1 parent a85f740 commit 83db3b5

10 files changed

Lines changed: 1735 additions & 1473 deletions

File tree

controllers/dependency_proxy_controller.go

Lines changed: 0 additions & 1452 deletions
This file was deleted.

controllers/dependencyfirewall/controller.go

Lines changed: 625 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
// Copyright (C) 2026 l3montree GmbH
2+
//
3+
// This program is free software: you can redistribute it and/or modify
4+
// it under the terms of the GNU Affero General Public License as
5+
// published by the Free Software Foundation, either version 3 of the
6+
// License, or (at your option) any later version.
7+
//
8+
// This program is distributed in the hope that it will be useful,
9+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
// GNU Affero General Public License for more details.
12+
//
13+
// You should have received a copy of the GNU Affero General Public License
14+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
15+
16+
package dependencyfirewall
17+
18+
import (
19+
"context"
20+
"encoding/json"
21+
"fmt"
22+
"log/slog"
23+
"net/http"
24+
"os"
25+
"regexp"
26+
"strings"
27+
"time"
28+
29+
"github.com/l3montree-dev/devguard/shared"
30+
"github.com/labstack/echo/v4"
31+
"go.opentelemetry.io/otel/attribute"
32+
"go.opentelemetry.io/otel/codes"
33+
"go.opentelemetry.io/otel/trace"
34+
)
35+
36+
const goProxyURL = "https://proxy.golang.org"
37+
38+
var (
39+
goProxyPrefixRe = regexp.MustCompile(`^/api/v1/dependency-proxy/(?:[^/]+/)?go(?:/|$)`)
40+
goPathRe = regexp.MustCompile(`^([^@]+)(?:@v/([^/]+))?`)
41+
)
42+
43+
func (d *DependencyProxyController) isGoCached(cachePath string) bool {
44+
info, err := os.Stat(cachePath)
45+
if err != nil {
46+
return false
47+
}
48+
49+
var maxAge time.Duration
50+
if strings.Contains(cachePath, "/@v/") {
51+
maxAge = 168 * time.Hour // 7 days
52+
} else {
53+
maxAge = 1 * time.Hour
54+
}
55+
56+
return time.Since(info.ModTime()) < maxAge
57+
}
58+
59+
func (d *DependencyProxyController) ProxyGo(c shared.Context) error {
60+
requestPath := TrimProxyPrefix(c.Request().URL.Path, GoProxy)
61+
62+
ctx, span := depProxyTracer.Start(c.Request().Context(), "dependency-proxy.go",
63+
trace.WithAttributes(
64+
attribute.String("proxy.ecosystem", "go"),
65+
attribute.String("proxy.path", requestPath),
66+
attribute.String("http.method", c.Request().Method),
67+
),
68+
)
69+
defer span.End()
70+
c.SetRequest(c.Request().WithContext(ctx))
71+
72+
// Only allow GET and HEAD for Go proxy
73+
if c.Request().Method != http.MethodGet && c.Request().Method != http.MethodHead {
74+
return echo.NewHTTPError(http.StatusMethodNotAllowed, "Method not allowed")
75+
}
76+
77+
configs, err := d.GetDependencyProxyConfigs(c)
78+
if err != nil {
79+
slog.Error("Error getting dependency proxy configs", "error", err)
80+
}
81+
82+
slog.Info("Proxy request", "proxy", "go", "method", c.Request().Method, "path", requestPath)
83+
84+
packageName, version := d.ParsePackageFromPath(GoProxy, requestPath)
85+
86+
// Requests with an explicit version (.info, .mod, .zip) go through the versioned handler.
87+
// Requests for @latest or @v/list go through the latest handler.
88+
if version != "" {
89+
return d.proxyGoExplicitVersion(c, ctx, span, configs, requestPath, packageName, version)
90+
}
91+
return d.proxyGoLatest(c, ctx, span, configs, requestPath, packageName)
92+
}
93+
94+
// proxyGoExplicitVersion handles Go proxy requests for a specific version (.info, .mod, .zip).
95+
func (d *DependencyProxyController) proxyGoExplicitVersion(c shared.Context, ctx context.Context, span trace.Span, configs DependencyProxyConfigs, requestPath, packageName, version string) error {
96+
cachePath := d.getCachePath(GoProxy, requestPath)
97+
98+
// Check config for not allowed patterns before doing anything else to fail fast
99+
notAllowed, notAllowedReason := d.CheckNotAllowedPackage(ctx, GoProxy, requestPath, configs)
100+
if notAllowed {
101+
slog.Warn("Blocked not allowed package", "proxy", "go", "path", requestPath, "reason", notAllowedReason)
102+
return d.blockNotAllowedPackage(c, GoProxy, requestPath, notAllowedReason)
103+
}
104+
105+
// Check for malicious packages BEFORE checking cache to prevent cache poisoning
106+
if blocked, reason := d.checkMaliciousPackage(ctx, GoProxy, requestPath); blocked {
107+
slog.Warn("Blocked malicious package", "proxy", "go", "path", requestPath, "reason", reason)
108+
// Also remove from cache if it exists to prevent serving cached malicious content
109+
if err := os.Remove(cachePath); err == nil {
110+
slog.Info("Removed malicious package from cache", "path", cachePath)
111+
}
112+
return d.blockMaliciousPackage(c, GoProxy, requestPath, reason)
113+
}
114+
115+
// Check cache
116+
if d.isGoCached(cachePath) {
117+
slog.Debug("Cache hit", "proxy", "go", "path", requestPath)
118+
data, err := os.ReadFile(cachePath)
119+
if err == nil {
120+
if d.VerifyCacheIntegrity(cachePath, data) {
121+
if configs.MinReleaseAge > 0 {
122+
if releaseTime, ok := d.ReadCachedReleaseTime(cachePath); ok {
123+
if time.Since(releaseTime) > time.Duration(configs.MinReleaseAge)*time.Hour {
124+
return d.blockTooNewPackage(c, GoProxy, requestPath, releaseTime, configs.MinReleaseAge)
125+
}
126+
span.SetAttributes(attribute.Bool("proxy.cache_hit", true))
127+
return d.writeGoResponse(c, data, requestPath, true)
128+
}
129+
// No cached release time - fall through to upstream to retrieve it
130+
slog.Debug("No cached release time for MinReleaseAge check, refetching", "proxy", "go", "path", requestPath)
131+
} else {
132+
span.SetAttributes(attribute.Bool("proxy.cache_hit", true))
133+
return d.writeGoResponse(c, data, requestPath, true)
134+
}
135+
} else {
136+
slog.Warn("Cache integrity verification failed, refetching", "proxy", "go", "path", requestPath)
137+
// Remove corrupted cache
138+
os.Remove(cachePath)
139+
os.Remove(cachePath + ".sha256")
140+
}
141+
} else {
142+
slog.Warn("Cache read error", "proxy", "go", "error", err)
143+
}
144+
}
145+
146+
span.SetAttributes(attribute.Bool("proxy.cache_hit", false))
147+
148+
// Fetch from upstream
149+
data, headers, statusCode, err := d.fetchFromUpstream(ctx, GoProxy, goProxyURL, requestPath, c.Request().Header, nil)
150+
if err != nil {
151+
span.RecordError(err)
152+
span.SetStatus(codes.Error, err.Error())
153+
slog.Error("Error fetching from upstream", "proxy", "go", "error", err)
154+
return echo.NewHTTPError(http.StatusBadGateway, "Failed to fetch from upstream")
155+
}
156+
157+
if statusCode != http.StatusOK {
158+
slog.Debug("Upstream returned non-OK status", "proxy", "go", "status", statusCode)
159+
for key, values := range headers {
160+
for _, value := range values {
161+
c.Response().Header().Add(key, value)
162+
}
163+
}
164+
return c.Blob(statusCode, headers.Get("Content-Type"), data)
165+
}
166+
167+
_, releaseTime, hasReleaseTime := d.ExtractGoVersionAndReleaseTime(data)
168+
169+
// Check MinReleaseAge for .info responses
170+
if configs.MinReleaseAge > 0 && hasReleaseTime && strings.HasSuffix(requestPath, ".info") {
171+
if time.Since(releaseTime) > time.Duration(configs.MinReleaseAge)*time.Hour {
172+
return d.blockTooNewPackage(c, GoProxy, requestPath, releaseTime, configs.MinReleaseAge)
173+
}
174+
}
175+
176+
if err := d.CacheDataWithIntegrity(cachePath, data); err != nil {
177+
slog.Warn("Failed to cache response", "proxy", "go", "error", err)
178+
}
179+
180+
// Store release time so MinReleaseAge can be enforced on future cache hits
181+
if hasReleaseTime && strings.HasSuffix(requestPath, ".info") {
182+
if err := d.CacheReleaseTime(cachePath, releaseTime); err != nil {
183+
slog.Warn("Failed to cache release time", "proxy", "go", "error", err)
184+
}
185+
}
186+
187+
if contentType := headers.Get("Content-Type"); contentType != "" {
188+
c.Response().Header().Set("Content-Type", contentType)
189+
}
190+
if dockerContentDigest := headers.Get("Docker-Content-Digest"); dockerContentDigest != "" {
191+
c.Response().Header().Set("Docker-Content-Digest", dockerContentDigest)
192+
}
193+
194+
return d.writeGoResponse(c, data, requestPath, false)
195+
}
196+
197+
// proxyGoLatest handles Go proxy requests for @latest and @v/list (version-resolution requests).
198+
func (d *DependencyProxyController) proxyGoLatest(c shared.Context, ctx context.Context, span trace.Span, configs DependencyProxyConfigs, requestPath, packageName string) error {
199+
cachePath := d.getCachePath(GoProxy, requestPath)
200+
201+
span.SetAttributes(attribute.Bool("proxy.cache_hit", false))
202+
203+
// Fetch from upstream — we need the response to resolve the version before we can check rules
204+
data, headers, statusCode, err := d.fetchFromUpstream(ctx, GoProxy, goProxyURL, requestPath, c.Request().Header, nil)
205+
if err != nil {
206+
span.RecordError(err)
207+
span.SetStatus(codes.Error, err.Error())
208+
slog.Error("Error fetching from upstream", "proxy", "go", "error", err)
209+
return echo.NewHTTPError(http.StatusBadGateway, "Failed to fetch from upstream")
210+
}
211+
212+
if statusCode != http.StatusOK {
213+
slog.Debug("Upstream returned non-OK status", "proxy", "go", "status", statusCode)
214+
for key, values := range headers {
215+
for _, value := range values {
216+
c.Response().Header().Add(key, value)
217+
}
218+
}
219+
return c.Blob(statusCode, headers.Get("Content-Type"), data)
220+
}
221+
222+
resolvedVersion, releaseTime, hasReleaseTime := d.ExtractGoVersionAndReleaseTime(data)
223+
224+
if resolvedVersion != "" {
225+
notAllowed, notAllowedReason := d.CheckNotAllowedPackage(ctx, GoProxy, packageName+"@"+resolvedVersion, configs)
226+
if notAllowed {
227+
slog.Warn("Blocked not allowed package", "proxy", "go", "path", requestPath, "reason", notAllowedReason)
228+
return d.blockNotAllowedPackage(c, GoProxy, requestPath, notAllowedReason)
229+
}
230+
231+
slog.Debug("Checking resolved version for malicious package", "package", packageName, "version", resolvedVersion)
232+
isMalicious, entry, err := d.maliciousChecker.IsMalicious(ctx, "go", packageName, resolvedVersion)
233+
if err != nil {
234+
slog.Error("Error checking malicious package", "proxy", "go", "error", err)
235+
return echo.NewHTTPError(500, "failed to check if package is malicious").WithInternal(err)
236+
}
237+
if isMalicious {
238+
reason := fmt.Sprintf("Package %s@%s is flagged as malicious (ID: %s)", packageName, resolvedVersion, entry.ID)
239+
if entry.Summary != "" {
240+
reason += ": " + entry.Summary
241+
}
242+
slog.Warn("Blocked malicious package after version resolution", "proxy", "go", "package", packageName, "version", resolvedVersion, "reason", reason)
243+
return d.blockMaliciousPackage(c, GoProxy, requestPath, reason)
244+
}
245+
}
246+
247+
// Check MinReleaseAge for resolved-version responses (e.g. /@latest)
248+
if configs.MinReleaseAge > 0 && hasReleaseTime && resolvedVersion != "" {
249+
if time.Since(releaseTime) > time.Duration(configs.MinReleaseAge)*time.Hour {
250+
return d.blockTooNewPackage(c, GoProxy, requestPath, releaseTime, configs.MinReleaseAge)
251+
}
252+
}
253+
254+
if err := d.CacheDataWithIntegrity(cachePath, data); err != nil {
255+
slog.Warn("Failed to cache response", "proxy", "go", "error", err)
256+
}
257+
258+
if hasReleaseTime && resolvedVersion != "" {
259+
if err := d.CacheReleaseTime(cachePath, releaseTime); err != nil {
260+
slog.Warn("Failed to cache release time", "proxy", "go", "error", err)
261+
}
262+
}
263+
264+
if contentType := headers.Get("Content-Type"); contentType != "" {
265+
c.Response().Header().Set("Content-Type", contentType)
266+
}
267+
if dockerContentDigest := headers.Get("Docker-Content-Digest"); dockerContentDigest != "" {
268+
c.Response().Header().Set("Docker-Content-Digest", dockerContentDigest)
269+
}
270+
271+
return d.writeGoResponse(c, data, requestPath, false)
272+
}
273+
274+
func (d *DependencyProxyController) writeGoResponse(c shared.Context, data []byte, path string, cached bool) error {
275+
if c.Response().Header().Get("Content-Type") == "" {
276+
contentType := "text/plain; charset=utf-8"
277+
if strings.HasSuffix(path, ".zip") {
278+
contentType = "application/zip"
279+
}
280+
c.Response().Header().Set("Content-Type", contentType)
281+
}
282+
283+
if cached {
284+
c.Response().Header().Set("X-Cache", "HIT")
285+
} else {
286+
c.Response().Header().Set("X-Cache", "MISS")
287+
}
288+
289+
c.Response().Header().Set("X-Proxy-Type", "go")
290+
return c.Blob(http.StatusOK, c.Response().Header().Get("Content-Type"), data)
291+
}
292+
293+
// ExtractGoVersionAndReleaseTime parses a Go proxy .info response and returns the resolved version and its release time.
294+
func (d *DependencyProxyController) ExtractGoVersionAndReleaseTime(data []byte) (string, time.Time, bool) {
295+
var info struct {
296+
Version string `json:"Version"`
297+
Time time.Time `json:"Time"`
298+
}
299+
if err := json.Unmarshal(data, &info); err != nil || info.Time.IsZero() {
300+
return "", time.Time{}, false
301+
}
302+
return info.Version, info.Time, true
303+
}

0 commit comments

Comments
 (0)