Skip to content
Merged
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
2 changes: 1 addition & 1 deletion audit/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ type Auditor interface {
// Request represents information about an HTTP request for auditing
type Request struct {
Method string
URL string
URL string // The fully qualified request URL (scheme, domain, optional path).
Host string
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Including the host here seems redundant if the URL is fully qualified. Should we remove it?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think for analyzing logs and building dashboards based on logs - it will be easier to have structured logs:

  • scheme
  • domain
  • path
  • query_params
    ?

Probably we'll want to run some aggregations queries based on domain? It may be harder to run aggregations queries if we only have string with fully qualified URL?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now I'm inclined to just make this the fully-qualified URL so that the application logs are accurate and the consumers actually get the full URL. Consumers can parse it, if desired.

That said, I think it would make sense to ultimately structure the logs a bit better in coder. I'll see how things pan out after dogfooding for a bit.

Allowed bool
Rule string // The rule that matched (if any)
Expand Down
21 changes: 16 additions & 5 deletions proxy/proxy.go
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before:
time=2026-01-07T20:23:10.453Z level=INFO msg=ALLOW method=GET url=/test host=httpforever.com rule="domain=httpforever.com”

After:
time=2026-01-07T20:24:02.912Z level=INFO msg=ALLOW method=GET url=http://httpforever.com/test host=httpforever.com rule="domain=httpforever.com"

Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ func (p *Server) handleHTTPConnection(conn net.Conn) {
return
}

p.logger.Debug("🌐 HTTP Request: %s %s", req.Method, req.URL.String())
p.logger.Debug("🌐 HTTP Request", "method", req.Method, "url", req.URL.String())
p.processHTTPRequest(conn, req, false)
}

Expand Down Expand Up @@ -261,13 +261,24 @@ func (p *Server) processHTTPRequest(conn net.Conn, req *http.Request, https bool
p.logger.Debug(" Host", "host", req.Host)
p.logger.Debug(" User-Agent", "user-agent", req.Header.Get("User-Agent"))

// Check if request should be allowed
result := p.ruleEngine.Evaluate(req.Method, req.Host+req.URL.String())
// Construct fully qualified URL for rule evaluation and auditing.
// In boundary's normal transparent proxy operation, req.URL only contains
// the path since clients don't know they're going through a proxy.
// When clients explicitly configure a proxy, req.URL contains the full URL.
fullURL := req.URL.String()
if req.URL.Scheme == "" {
scheme := "http"
if https {
scheme = "https"
}
fullURL = scheme + "://" + req.Host + fullURL
}

result := p.ruleEngine.Evaluate(req.Method, fullURL)

// Audit the request
p.auditor.AuditRequest(audit.Request{
Method: req.Method,
URL: req.URL.String(),
URL: fullURL,
Host: req.Host,
Allowed: result.Allowed,
Rule: result.Rule,
Expand Down
132 changes: 132 additions & 0 deletions proxy/proxy_audit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package proxy

import (
"net"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"

"github.com/coder/boundary/audit"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// capturingAuditor captures all audit requests for test verification.
type capturingAuditor struct {
mu sync.Mutex
requests []audit.Request
}

func (c *capturingAuditor) AuditRequest(req audit.Request) {
c.mu.Lock()
defer c.mu.Unlock()
c.requests = append(c.requests, req)
}

func (c *capturingAuditor) getRequests() []audit.Request {
c.mu.Lock()
defer c.mu.Unlock()
return append([]audit.Request{}, c.requests...)
}

func TestAuditURLIsFullyFormed_HTTP(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

serverURL, err := url.Parse(server.URL)
require.NoError(t, err)

auditor := &capturingAuditor{}

pt := NewProxyTest(t,
WithCertManager(t.TempDir()),
WithAllowedRule("domain="+serverURL.Hostname()+" path=/allowed/*"),
WithAuditor(auditor),
).Start()
defer pt.Stop()

t.Run("allowed", func(t *testing.T) {
resp, err := pt.proxyClient.Get(server.URL + "/allowed/path?q=1")
require.NoError(t, err)
defer func() {
err = resp.Body.Close()
require.NoError(t, err)
}()
require.Equal(t, http.StatusOK, resp.StatusCode)

requests := auditor.getRequests()
require.NotEmpty(t, requests)

req := requests[len(requests)-1]
require.True(t, req.Allowed)

expectedURL := "http://" + net.JoinHostPort(serverURL.Hostname(), serverURL.Port()) + "/allowed/path?q=1"
assert.Equal(t, expectedURL, req.URL)
})

t.Run("denied", func(t *testing.T) {
resp, err := pt.proxyClient.Get(server.URL + "/denied/path")
require.NoError(t, err)
defer func() {
err = resp.Body.Close()
require.NoError(t, err)
}()
require.Equal(t, http.StatusForbidden, resp.StatusCode)

requests := auditor.getRequests()
require.NotEmpty(t, requests)

req := requests[len(requests)-1]
require.False(t, req.Allowed)

expectedURL := "http://" + net.JoinHostPort(serverURL.Hostname(), serverURL.Port()) + "/denied/path"
assert.Equal(t, expectedURL, req.URL)
})
}

func TestAuditURLIsFullyFormed_HTTPS(t *testing.T) {
auditor := &capturingAuditor{}

pt := NewProxyTest(t,
WithCertManager(t.TempDir()),
WithAllowedDomain("dev.coder.com"),
WithAuditor(auditor),
).Start()
defer pt.Stop()

tunnel, err := pt.establishExplicitCONNECT("dev.coder.com:443")
require.NoError(t, err)
defer func() {
assert.NoError(t, tunnel.close())
}()

t.Run("allowed", func(t *testing.T) {
_, err := tunnel.sendRequest("dev.coder.com", "/api/v2?q=1")
require.NoError(t, err)

requests := auditor.getRequests()
require.NotEmpty(t, requests)

req := requests[len(requests)-1]
require.True(t, req.Allowed)

assert.Equal(t, "https://dev.coder.com/api/v2?q=1", req.URL)
})

t.Run("denied", func(t *testing.T) {
err := tunnel.sendRequestAndExpectDeny("blocked.example.com", "/some/path")
require.NoError(t, err)

requests := auditor.getRequests()
require.NotEmpty(t, requests)

req := requests[len(requests)-1]
require.False(t, req.Allowed)

assert.Equal(t, "https://blocked.example.com/some/path", req.URL)
})
}
15 changes: 14 additions & 1 deletion proxy/proxy_framework_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type ProxyTest struct {
configDir string
startupDelay time.Duration
allowedRules []string
auditor audit.Auditor
}

// ProxyTestOption is a function that configures ProxyTest
Expand Down Expand Up @@ -100,6 +101,13 @@ func WithAllowedRule(rule string) ProxyTestOption {
}
}

// WithAuditor sets a custom auditor for capturing audit requests
func WithAuditor(auditor audit.Auditor) ProxyTestOption {
return func(pt *ProxyTest) {
pt.auditor = auditor
}
}

// Start starts the proxy server
func (pt *ProxyTest) Start() *ProxyTest {
pt.t.Helper()
Expand All @@ -112,7 +120,12 @@ func (pt *ProxyTest) Start() *ProxyTest {
require.NoError(pt.t, err, "Failed to parse test rules")

ruleEngine := rulesengine.NewRuleEngine(testRules, logger)
auditor := &mockAuditor{}

// Use custom auditor if provided, otherwise use no-op mock
auditor := pt.auditor
if auditor == nil {
auditor = &mockAuditor{}
}

var tlsConfig *tls.Config
if pt.useCertManager {
Expand Down
Loading