Compare commits

...

6 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] ac6101b6bf Fix Unix socket HTTPS/HTTP issue in OpenGraph fetching
Co-authored-by: JasonLovesDoggo <66544866+JasonLovesDoggo@users.noreply.github.com>
2025-08-24 03:36:36 +00:00
copilot-swe-agent[bot] 9a85f0b053 Initial investigation into Unix socket HTTPS/HTTP issue
Co-authored-by: JasonLovesDoggo <66544866+JasonLovesDoggo@users.noreply.github.com>
2025-08-24 03:30:41 +00:00
copilot-swe-agent[bot] bdb8d5c249 Initial plan 2025-08-24 03:17:57 +00:00
Skyler Mäntysaari d1d631a18a lib/checker: Implement X-Original-URI support (#1015) 2025-08-23 23:14:37 -04:00
Timo Tijhof f3cd6c9ca4 docs: fix "stored" typo in CHANGELOG.md (#1008)
Co-authored-by: Jason Cameron <git@jasoncameron.dev>
2025-08-24 03:12:08 +00:00
Brad Parbs 23772fd3cb s/Wordpress/WordPress in docs (#1020)
Signed-off-by: Brad Parbs <brad@bradparbs.com>
2025-08-24 02:52:09 +00:00
7 changed files with 229 additions and 11 deletions
+3 -2
View File
@@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
<!-- This changes the project to: --> <!-- This changes the project to: -->
- Added a missiling link to the Caddy installation environment in the installation documentation. - Added a missing link to the Caddy installation environment in the installation documentation.
- Downstream consumers can change the default [log/slog#Logger](https://pkg.go.dev/log/slog#Logger) instance that Anubis uses by setting `opts.Logger` to your slog instance of choice ([#864](https://github.com/TecharoHQ/anubis/issues/864)). - Downstream consumers can change the default [log/slog#Logger](https://pkg.go.dev/log/slog#Logger) instance that Anubis uses by setting `opts.Logger` to your slog instance of choice ([#864](https://github.com/TecharoHQ/anubis/issues/864)).
- The [Thoth client](https://anubis.techaro.lol/docs/admin/thoth) is now public in the repo instead of being an internal package. - The [Thoth client](https://anubis.techaro.lol/docs/admin/thoth) is now public in the repo instead of being an internal package.
- [Custom-AsyncHttpClient](https://github.com/AsyncHttpClient/async-http-client)'s default User-Agent has an increased weight by default ([#852](https://github.com/TecharoHQ/anubis/issues/852)). - [Custom-AsyncHttpClient](https://github.com/AsyncHttpClient/async-http-client)'s default User-Agent has an increased weight by default ([#852](https://github.com/TecharoHQ/anubis/issues/852)).
@@ -40,12 +40,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Bump AI-robots.txt to version 1.39 - Bump AI-robots.txt to version 1.39
- Add a default block rule for Huawei Cloud. - Add a default block rule for Huawei Cloud.
- Add a default block rule for Alibaba Cloud. - Add a default block rule for Alibaba Cloud.
- Add X-Request-URI support so that Subrequest Authentication has path support.
### Security-relevant changes ### Security-relevant changes
#### Fix potential double-spend for challenges #### Fix potential double-spend for challenges
Anubis operates by issuing a challenge and having the client present a solution for that challenge. Challenges are identified by a unique UUID, which is tored in the database. Anubis operates by issuing a challenge and having the client present a solution for that challenge. Challenges are identified by a unique UUID, which is stored in the database.
The problem is that a challenge could potentially be used twice by a dedicated attacker making a targeted attack against Anubis. Challenge records did not have a "spent" or "used" field. In total, a dedicated attacker could solve a challenge once and reuse that solution across multiple sessions in order to mint additional tokens. The problem is that a challenge could potentially be used twice by a dedicated attacker making a targeted attack against Anubis. Challenge records did not have a "spent" or "used" field. In total, a dedicated attacker could solve a challenge once and reuse that solution across multiple sessions in order to mint additional tokens.
+4 -4
View File
@@ -1,6 +1,6 @@
# Wordpress # WordPress
Wordpress is the most popular blog engine on the planet. WordPress is the most popular blog engine on the planet.
## Using a multi-site setup with Anubis ## Using a multi-site setup with Anubis
@@ -27,7 +27,7 @@ flowchart LR
US --> |whatever you're doing| B US --> |whatever you're doing| B
``` ```
Wordpress may not realize that the underlying connection is being done over HTTPS. This could lead to a redirect loop in the `/wp-admin/` routes. In order to fix this, add the following to your `wp-config.php` file: WordPress may not realize that the underlying connection is being done over HTTPS. This could lead to a redirect loop in the `/wp-admin/` routes. In order to fix this, add the following to your `wp-config.php` file:
```php ```php
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') { if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
@@ -36,4 +36,4 @@ if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROT
} }
``` ```
This will make Wordpress think that your connection is over HTTPS instead of plain HTTP. This will make WordPress think that your connection is over HTTPS instead of plain HTTP.
+20 -1
View File
@@ -21,6 +21,24 @@ const (
querySeparatorLength = 1 // Length of "?" for query strings querySeparatorLength = 1 // Length of "?" for query strings
) )
// unixRoundTripper wraps an http.Transport to handle Unix socket requests properly
// by ensuring the URL scheme is set to "http" to avoid TLS issues.
// Based on the UnixRoundTripper implementation in lib/http.go
type unixRoundTripper struct {
Transport *http.Transport
}
// RoundTrip implements the http.RoundTripper interface
func (t *unixRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
if req.Host == "" {
req.Host = "localhost"
}
req.URL.Host = req.Host
req.URL.Scheme = "http" // Ensure HTTP scheme to avoid "server gave HTTP response to HTTPS client" error
return t.Transport.RoundTrip(req)
}
type OGTagCache struct { type OGTagCache struct {
cache store.JSON[map[string]string] cache store.JSON[map[string]string]
targetURL *url.URL targetURL *url.URL
@@ -69,11 +87,12 @@ func NewOGTagCache(target string, conf config.OpenGraph, backend store.Interface
// Configure custom transport for Unix sockets // Configure custom transport for Unix sockets
if parsedTargetURL.Scheme == "unix" { if parsedTargetURL.Scheme == "unix" {
socketPath := parsedTargetURL.Path // For unix scheme, path is the socket path socketPath := parsedTargetURL.Path // For unix scheme, path is the socket path
client.Transport = &http.Transport{ transport := &http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", socketPath) return net.Dial("unix", socketPath)
}, },
} }
client.Transport = &unixRoundTripper{Transport: transport}
} }
return &OGTagCache{ return &OGTagCache{
+4 -4
View File
@@ -99,16 +99,16 @@ func TestNewOGTagCache_UnixSocket(t *testing.T) {
} }
// Check if the client transport is configured for Unix sockets // Check if the client transport is configured for Unix sockets
transport, ok := cache.client.Transport.(*http.Transport) roundTripper, ok := cache.client.Transport.(*unixRoundTripper)
if !ok { if !ok {
t.Fatalf("expected client transport to be *http.Transport, got %T", cache.client.Transport) t.Fatalf("expected client transport to be *unixRoundTripper, got %T", cache.client.Transport)
} }
if transport.DialContext == nil { if roundTripper.Transport.DialContext == nil {
t.Fatal("expected client transport DialContext to be non-nil for unix socket") t.Fatal("expected client transport DialContext to be non-nil for unix socket")
} }
// Attempt a dummy dial to see if it uses the correct path (optional, more involved check) // Attempt a dummy dial to see if it uses the correct path (optional, more involved check)
dummyConn, err := transport.DialContext(context.Background(), "", "") dummyConn, err := roundTripper.Transport.DialContext(context.Background(), "", "")
if err == nil { if err == nil {
dummyConn.Close() dummyConn.Close()
t.Log("DialContext seems functional, but couldn't verify path without a listener") t.Log("DialContext seems functional, but couldn't verify path without a listener")
@@ -0,0 +1,98 @@
package ogtags
import (
"context"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/TecharoHQ/anubis/lib/store/memory"
)
// TestUnixSocketHTTPSFix tests that the fix prevents "http: server gave HTTP response to HTTPS client" errors
// when using Unix socket targets with HTTPS input URLs
func TestUnixSocketHTTPSFix(t *testing.T) {
tempDir := t.TempDir()
socketPath := filepath.Join(tempDir, "test.sock")
// Create a simple HTTP server listening on the Unix socket
server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify that the request comes in with HTTP (not HTTPS)
if r.URL.Scheme != "" && r.URL.Scheme != "http" {
t.Errorf("Unexpected scheme in request: %s (expected 'http' or empty)", r.URL.Scheme)
}
if r.TLS != nil {
t.Errorf("Request has TLS information when it shouldn't for Unix socket")
}
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<html><head><meta property="og:title" content="Test Title"></head></html>`))
}),
}
// Listen on Unix socket
listener, err := net.Listen("unix", socketPath)
if err != nil {
t.Fatalf("Failed to create Unix socket listener: %v", err)
}
defer os.Remove(socketPath)
defer listener.Close()
// Start the server
go func() {
server.Serve(listener)
}()
defer server.Close()
// Wait a bit for server to start
time.Sleep(100 * time.Millisecond)
// Create OGTagCache with Unix socket target
target := "unix://" + socketPath
cache := NewOGTagCache(target, config.OpenGraph{
Enabled: true,
TimeToLive: time.Minute,
}, memory.New(t.Context()))
// Test cases that previously might have caused the "HTTP response to HTTPS client" error
testCases := []struct {
name string
url string
}{
{"HTTPS URL", "https://example.com/test"},
{"HTTPS with port", "https://example.com:443/test"},
{"HTTPS with query", "https://example.com/test?param=value"},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
inputURL, _ := url.Parse(tc.url)
// This should succeed without the "server gave HTTP response to HTTPS client" error
ogTags, err := cache.GetOGTags(context.Background(), inputURL, "example.com")
if err != nil {
if strings.Contains(err.Error(), "server gave HTTP response to HTTPS client") {
t.Errorf("Fix did not work: still getting HTTPS/HTTP error: %v", err)
} else {
// Other errors are acceptable for this test
t.Logf("Got non-HTTPS error (acceptable): %v", err)
}
} else {
// Success case
if ogTags["og:title"] != "Test Title" {
t.Errorf("Expected og:title 'Test Title', got: %v", ogTags["og:title"])
}
t.Logf("Success: got expected og:title = %s", ogTags["og:title"])
}
})
}
}
+7
View File
@@ -102,6 +102,13 @@ func NewPathChecker(rexStr string) (checker.Impl, error) {
} }
func (pc *PathChecker) Check(r *http.Request) (bool, error) { func (pc *PathChecker) Check(r *http.Request) (bool, error) {
originalUrl := r.Header.Get("X-Original-URI")
if originalUrl != "" {
if pc.regexp.MatchString(originalUrl) {
return true, nil
}
}
if pc.regexp.MatchString(r.URL.Path) { if pc.regexp.MatchString(r.URL.Path) {
return true, nil return true, nil
} }
+93
View File
@@ -198,3 +198,96 @@ func TestHeaderExistsChecker(t *testing.T) {
}) })
} }
} }
func TestPathChecker_XOriginalURI(t *testing.T) {
tests := []struct {
name string
regex string
xOriginalURI string
urlPath string
headerKey string
expectedMatch bool
expectError bool
}{
{
name: "X-Original-URI matches regex (with trailing space - current typo)",
regex: "^/api/.*",
xOriginalURI: "/api/users",
urlPath: "/different/path",
headerKey: "X-Original-URI",
expectedMatch: true,
expectError: false,
},
{
name: "X-Original-URI doesn't match, falls back to URL.Path",
regex: "^/admin/.*",
xOriginalURI: "/api/users",
urlPath: "/admin/dashboard",
headerKey: "X-Original-URI",
expectedMatch: true,
expectError: false,
},
{
name: "Neither X-Original-URI nor URL.Path match",
regex: "^/admin/.*",
xOriginalURI: "/api/users",
urlPath: "/public/info",
headerKey: "X-Original-URI ",
expectedMatch: false,
expectError: false,
},
{
name: "Empty X-Original-URI, URL.Path matches",
regex: "^/static/.*",
xOriginalURI: "",
urlPath: "/static/css/style.css",
headerKey: "X-Original-URI",
expectedMatch: true,
expectError: false,
},
{
name: "Complex regex matching X-Original-URI",
regex: `^/api/v[0-9]+/(users|posts)/[0-9]+$`,
xOriginalURI: "/api/v1/users/123",
urlPath: "/different",
headerKey: "X-Original-URI",
expectedMatch: true,
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create the PathChecker
pc, err := NewPathChecker(tt.regex)
if err != nil {
if !tt.expectError {
t.Fatalf("NewPathChecker() unexpected error: %v", err)
}
return
}
if tt.expectError {
t.Fatal("NewPathChecker() expected error but got none")
}
req, err := http.NewRequest("GET", "http://example.com"+tt.urlPath, nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
if tt.xOriginalURI != "" {
req.Header.Set(tt.headerKey, tt.xOriginalURI)
}
match, err := pc.Check(req)
if err != nil {
t.Fatalf("Check() unexpected error: %v", err)
}
if match != tt.expectedMatch {
t.Errorf("Check() = %v, want %v", match, tt.expectedMatch)
}
})
}
}