Compare commits

..

3 Commits

Author SHA1 Message Date
Jason Cameron
818a1844d0 Merge branch 'main' of https://github.com/TecharoHQ/anubis into docs/fix-1080 2025-11-16 19:11:01 -05:00
Jason Cameron
31e854865c docs: add note about unsetting PUBLIC_URL in Kubernetes setup 2025-11-16 18:42:45 -05:00
Jason Cameron
2a6678283f docs: clarify usage of PUBLIC_URL and REDIRECT_DOMAINS in installation guide
Closes: 1080
2025-11-16 18:31:29 -05:00
23 changed files with 217 additions and 870 deletions

View File

@@ -8,5 +8,4 @@ msgbox
xeact
ABee
tencent
maintnotifications
azurediamond
maintnotifications

View File

@@ -200,7 +200,6 @@ licstart
lightpanda
limsa
Linting
listor
LLU
loadbalancer
lol
@@ -218,10 +217,6 @@ mnt
Mojeek
mojeekbot
mozilla
myclient
mymaster
mypass
myuser
nbf
nepeat
netsurf
@@ -272,6 +267,7 @@ qwantbot
rac
rawler
rcvar
rdb
redhat
redir
redirectscheme

View File

@@ -439,29 +439,26 @@ func main() {
}
s, err := libanubis.New(libanubis.Options{
BasePrefix: *basePrefix,
StripBasePrefix: *stripBasePrefix,
Next: rp,
Policy: policy,
TargetHost: *targetHost,
TargetSNI: *targetSNI,
TargetInsecureSkipVerify: *targetInsecureSkipVerify,
ServeRobotsTXT: *robotsTxt,
ED25519PrivateKey: ed25519Priv,
HS512Secret: []byte(*hs512Secret),
CookieDomain: *cookieDomain,
CookieDynamicDomain: *cookieDynamicDomain,
CookieExpiration: *cookieExpiration,
CookiePartitioned: *cookiePartitioned,
RedirectDomains: redirectDomainsList,
Target: *target,
WebmasterEmail: *webmasterEmail,
OpenGraph: policy.OpenGraph,
CookieSecure: *cookieSecure,
CookieSameSite: parseSameSite(*cookieSameSite),
PublicUrl: *publicUrl,
JWTRestrictionHeader: *jwtRestrictionHeader,
DifficultyInJWT: *difficultyInJWT,
BasePrefix: *basePrefix,
StripBasePrefix: *stripBasePrefix,
Next: rp,
Policy: policy,
ServeRobotsTXT: *robotsTxt,
ED25519PrivateKey: ed25519Priv,
HS512Secret: []byte(*hs512Secret),
CookieDomain: *cookieDomain,
CookieDynamicDomain: *cookieDynamicDomain,
CookieExpiration: *cookieExpiration,
CookiePartitioned: *cookiePartitioned,
RedirectDomains: redirectDomainsList,
Target: *target,
WebmasterEmail: *webmasterEmail,
OpenGraph: policy.OpenGraph,
CookieSecure: *cookieSecure,
CookieSameSite: parseSameSite(*cookieSameSite),
PublicUrl: *publicUrl,
JWTRestrictionHeader: *jwtRestrictionHeader,
DifficultyInJWT: *difficultyInJWT,
})
if err != nil {
log.Fatalf("can't construct libanubis.Server: %v", err)

View File

@@ -21,7 +21,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Allow Renovate as an OCI registry client.
- Properly handle 4in6 addresses so that IP matching works with those addresses.
- Add support to simple Valkey/Redis cluster mode
- Open Graph passthrough now reuses the configured target Host/SNI/TLS settings, so metadata fetches succeed when the upstream certificate differs from the public domain. ([1283](https://github.com/TecharoHQ/anubis/pull/1283))
- Stabilize the CVE-2025-24369 regression test by always submitting an invalid proof instead of relying on random POW failures.
## v1.23.1: Lyse Hext - Echo 1

View File

@@ -225,10 +225,10 @@ Using this backend will cause a lot of S3 operations, at least one for creating
The `s3api` backend takes the following configuration options:
| Name | Type | Example | Description |
| :----------- | :------ | :------------ | :------------------------------------------------------------------------------------------------------------------------------------------ |
| `bucketName` | string | `anubis-data` | (Required) The name of the dedicated bucket for Anubis to store information in. |
| `pathStyle` | boolean | `false` | If true, use path-style S3 API operations. Please consult your storage provider's documentation if you don't know what you should put here. |
| Name | Type | Example | Description |
| :----------- | :------ | :------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------ |
| `bucketName` | string | The name of the dedicated bucket for Anubis to store information in. |
| `pathStyle` | boolean | `false` | If true, use path-style S3 API operations. Please consult your storage provider's documentation if you don't know what you should put here. |
:::note
@@ -279,7 +279,7 @@ store:
:::note
You can also use [Redis](http://redis.io/) with Anubis.
You can also use [Redis](http://redis.io/) with Anubis.
:::
@@ -291,17 +291,15 @@ This backend is ideal if you are running multiple instances of Anubis in a worke
| Does your service get a lot of traffic? | ✅ Yes |
| Do you want to store data persistently when Anubis restarts? | ✅ Yes |
| Do you run Anubis without mutable filesystem storage? | ✅ Yes |
| Do you have Redis or Valkey installed? | ✅ Yes |
| Do you have Redis or Valkey installed? | ✅ Yes |
#### Configuration
The `valkey` backend takes the following configuration options:
| Name | Type | Example | Description |
| :--------- | :----- | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ |
| `cluster` | bool | `false` | If true, use [Redis™ Clustering](https://redis.io/topics/cluster-spec) for storing Anubis data. |
| `sentinel` | object | `{}` | See [Redis™ Sentinel docs](#redis-sentinel) for more detail and examples |
| `url` | string | `redis://valkey:6379/0` | The URL for the instance of Redis™ or Valkey that Anubis should store data in. This is in the same format as `REDIS_URL` in many cloud providers. |
| Name | Type | Example | Description |
| :---- | :----- | :---------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------- |
| `url` | string | `redis://valkey:6379/0` | The URL for the instance of Redis or Valkey that Anubis should store data in. This is in the same format as `REDIS_URL` in many cloud providers. |
Example:
@@ -316,18 +314,6 @@ store:
This would have the Valkey client connect to host `valkey.int.techaro.lol` on port `6379` with database `0` (the default database).
#### Redis™ Sentinel
If you are using [Redis™ Sentinel](https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/) for a high availability setup, you need to configure the `sentinel` object. This object takes the following configuration options:
| Name | Type | Example | Description |
| :----------- | :----------------------- | :-------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `addr` | string or list of string | `10.43.208.130:26379` | (Required) The host and port of the Redis™ Sentinel server. When possible, use DNS names for this. If you have multiple addresses, supply a list of them. |
| `clientName` | string | `Anubis` | The client name reported to Redis™ Sentinel. Set this if you want to track Anubis connections to your Redis™ Sentinel. |
| `masterName` | string | `mymaster` | (Required) The name of the master in the Redis™ Sentinel configuration. This is used to discover where to find client connection hosts/ports. |
| `username` | string | `azurediamond` | The username used to authenticate against the Redis™ Sentinel and Redis™ servers. |
| `password` | string | `hunter2` | The password used to authenticate against the Redis™ Sentinel and Redis™ servers. |
## Risk calculation for downstream services
In case your service needs it for risk calculation reasons, Anubis exposes information about the rules that any requests match using a few headers:

View File

@@ -1,39 +0,0 @@
package internal
import (
"encoding/json"
)
// ListOr[T any] is a slice that can contain either a single T or multiple T values.
// During JSON unmarshaling, it checks if the first character is '[' to determine
// whether to treat the JSON as an array or a single value.
type ListOr[T any] []T
func (lo *ListOr[T]) UnmarshalJSON(data []byte) error {
if len(data) == 0 {
return nil
}
// Check if first non-whitespace character is '['
firstChar := data[0]
for i := 0; i < len(data); i++ {
if data[i] != ' ' && data[i] != '\t' && data[i] != '\n' && data[i] != '\r' {
firstChar = data[i]
break
}
}
if firstChar == '[' {
// It's an array, unmarshal directly
return json.Unmarshal(data, (*[]T)(lo))
} else {
// It's a single value, unmarshal as a single item in a slice
var single T
if err := json.Unmarshal(data, &single); err != nil {
return err
}
*lo = ListOr[T]{single}
}
return nil
}

View File

@@ -1,79 +0,0 @@
package internal
import (
"encoding/json"
"testing"
)
func TestListOr_UnmarshalJSON(t *testing.T) {
t.Run("single value should be unmarshaled as single item", func(t *testing.T) {
var lo ListOr[string]
err := json.Unmarshal([]byte(`"hello"`), &lo)
if err != nil {
t.Fatalf("Failed to unmarshal single string: %v", err)
}
if len(lo) != 1 {
t.Fatalf("Expected 1 item, got %d", len(lo))
}
if lo[0] != "hello" {
t.Errorf("Expected 'hello', got %q", lo[0])
}
})
t.Run("array should be unmarshaled as multiple items", func(t *testing.T) {
var lo ListOr[string]
err := json.Unmarshal([]byte(`["hello", "world"]`), &lo)
if err != nil {
t.Fatalf("Failed to unmarshal array: %v", err)
}
if len(lo) != 2 {
t.Fatalf("Expected 2 items, got %d", len(lo))
}
if lo[0] != "hello" {
t.Errorf("Expected 'hello', got %q", lo[0])
}
if lo[1] != "world" {
t.Errorf("Expected 'world', got %q", lo[1])
}
})
t.Run("single number should be unmarshaled as single item", func(t *testing.T) {
var lo ListOr[int]
err := json.Unmarshal([]byte(`42`), &lo)
if err != nil {
t.Fatalf("Failed to unmarshal single number: %v", err)
}
if len(lo) != 1 {
t.Fatalf("Expected 1 item, got %d", len(lo))
}
if lo[0] != 42 {
t.Errorf("Expected 42, got %d", lo[0])
}
})
t.Run("array of numbers should be unmarshaled as multiple items", func(t *testing.T) {
var lo ListOr[int]
err := json.Unmarshal([]byte(`[1, 2, 3]`), &lo)
if err != nil {
t.Fatalf("Failed to unmarshal number array: %v", err)
}
if len(lo) != 3 {
t.Fatalf("Expected 3 items, got %d", len(lo))
}
if lo[0] != 1 || lo[1] != 2 || lo[2] != 3 {
t.Errorf("Expected [1, 2, 3], got %v", lo)
}
})
}

View File

@@ -24,7 +24,7 @@ func TestCacheReturnsDefault(t *testing.T) {
TimeToLive: time.Minute,
ConsiderHost: false,
Override: want,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
u, err := url.Parse("https://anubis.techaro.lol")
if err != nil {
@@ -52,7 +52,7 @@ func TestCheckCache(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
// Set up test data
urlStr := "http://example.com/page"
@@ -115,7 +115,7 @@ func TestGetOGTags(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
// Parse the test server URL
parsedURL, err := url.Parse(ts.URL)
@@ -271,7 +271,7 @@ func TestGetOGTagsWithHostConsideration(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: tc.ogCacheConsiderHost,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
for i, req := range tc.requests {
ogTags, err := cache.GetOGTags(t.Context(), parsedURL, req.host)

View File

@@ -27,29 +27,16 @@ func (c *OGTagCache) fetchHTMLDocumentWithCache(ctx context.Context, urlStr stri
}
// Set the Host header to the original host
var hostForRequest string
switch {
case c.targetHost != "":
hostForRequest = c.targetHost
case originalHost != "":
hostForRequest = originalHost
}
if hostForRequest != "" {
req.Host = hostForRequest
if originalHost != "" {
req.Host = originalHost
}
// Add proxy headers
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("User-Agent", "Anubis-OGTag-Fetcher/1.0") // For tracking purposes
serverName := hostForRequest
if serverName == "" {
serverName = req.URL.Hostname()
}
client := c.clientForSNI(serverName)
// Send the request
resp, err := client.Do(req)
resp, err := c.client.Do(req)
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {

View File

@@ -87,7 +87,7 @@ func TestFetchHTMLDocument(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
doc, err := cache.fetchHTMLDocument(t.Context(), ts.URL, "anything")
if tt.expectError {
@@ -118,7 +118,7 @@ func TestFetchHTMLDocumentInvalidURL(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
doc, err := cache.fetchHTMLDocument(t.Context(), "http://invalid.url.that.doesnt.exist.example", "anything")

View File

@@ -111,7 +111,7 @@ func TestIntegrationGetOGTags(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
// Create URL for test
testURL, _ := url.Parse(ts.URL)

View File

@@ -31,7 +31,7 @@ func BenchmarkGetTarget(b *testing.B) {
for _, tt := range tests {
b.Run(tt.name, func(b *testing.B) {
cache := NewOGTagCache(tt.target, config.OpenGraph{}, memory.New(b.Context()), TargetOptions{})
cache := NewOGTagCache(tt.target, config.OpenGraph{}, memory.New(b.Context()))
urls := make([]*url.URL, len(tt.paths))
for i, path := range tt.paths {
u, _ := url.Parse(path)
@@ -67,7 +67,7 @@ func BenchmarkExtractOGTags(b *testing.B) {
</head><body><div><p>Content</p></div></body></html>`,
}
cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(b.Context()), TargetOptions{})
cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(b.Context()))
docs := make([]*html.Node, len(htmlSamples))
for i, sample := range htmlSamples {
@@ -85,7 +85,7 @@ func BenchmarkExtractOGTags(b *testing.B) {
// Memory usage test
func TestMemoryUsage(t *testing.T) {
cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(t.Context()), TargetOptions{})
cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(t.Context()))
// Force GC and wait for it to complete
runtime.GC()

View File

@@ -2,13 +2,11 @@ package ogtags
import (
"context"
"crypto/tls"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/TecharoHQ/anubis/lib/policy/config"
@@ -24,34 +22,21 @@ const (
)
type OGTagCache struct {
ogOverride map[string]string
targetURL *url.URL
client *http.Client
transport *http.Transport
ogOverride map[string]string
cache store.JSON[map[string]string]
// Pre-built strings for optimization
unixPrefix string // "http://unix"
targetSNI string
targetHost string
approvedPrefixes []string
approvedTags []string
approvedPrefixes []string
ogTimeToLive time.Duration
ogPassthrough bool
ogCacheConsiderHost bool
targetSNIAuto bool
insecureSkipVerify bool
sniClients map[string]*http.Client
transportMu sync.RWMutex
ogPassthrough bool
}
type TargetOptions struct {
Host string
SNI string
InsecureSkipVerify bool
}
func NewOGTagCache(target string, conf config.OpenGraph, backend store.Interface, targetOpts TargetOptions) *OGTagCache {
func NewOGTagCache(target string, conf config.OpenGraph, backend store.Interface) *OGTagCache {
// Predefined approved tags and prefixes
defaultApprovedTags := []string{"description", "keywords", "author"}
defaultApprovedPrefixes := []string{"og:", "twitter:", "fediverse:"}
@@ -77,37 +62,20 @@ func NewOGTagCache(target string, conf config.OpenGraph, backend store.Interface
}
}
transport := http.DefaultTransport.(*http.Transport).Clone()
client := &http.Client{
Timeout: httpTimeout,
}
// Configure custom transport for Unix sockets
if parsedTargetURL.Scheme == "unix" {
socketPath := parsedTargetURL.Path // For unix scheme, path is the socket path
transport.DialContext = func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", socketPath)
client.Transport = &http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", socketPath)
},
}
}
targetSNIAuto := targetOpts.SNI == "auto"
if targetOpts.SNI != "" && !targetSNIAuto {
if transport.TLSClientConfig == nil {
transport.TLSClientConfig = &tls.Config{}
}
transport.TLSClientConfig.ServerName = targetOpts.SNI
}
if targetOpts.InsecureSkipVerify {
if transport.TLSClientConfig == nil {
transport.TLSClientConfig = &tls.Config{}
}
transport.TLSClientConfig.InsecureSkipVerify = true
}
client := &http.Client{
Timeout: httpTimeout,
Transport: transport,
}
return &OGTagCache{
cache: store.JSON[map[string]string]{
Underlying: backend,
@@ -121,13 +89,7 @@ func NewOGTagCache(target string, conf config.OpenGraph, backend store.Interface
approvedTags: defaultApprovedTags,
approvedPrefixes: defaultApprovedPrefixes,
client: client,
transport: transport,
unixPrefix: "http://unix",
targetHost: targetOpts.Host,
targetSNI: targetOpts.SNI,
targetSNIAuto: targetSNIAuto,
insecureSkipVerify: targetOpts.InsecureSkipVerify,
sniClients: make(map[string]*http.Client),
}
}

View File

@@ -48,7 +48,7 @@ func FuzzGetTarget(f *testing.F) {
}
// Create cache - should not panic
cache := NewOGTagCache(target, config.OpenGraph{}, memory.New(context.Background()), TargetOptions{})
cache := NewOGTagCache(target, config.OpenGraph{}, memory.New(context.Background()))
// Create URL
u := &url.URL{
@@ -132,7 +132,7 @@ func FuzzExtractOGTags(f *testing.F) {
return
}
cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(context.Background()), TargetOptions{})
cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(context.Background()))
// Should not panic
tags := cache.extractOGTags(doc)
@@ -188,7 +188,7 @@ func FuzzGetTargetRoundTrip(f *testing.F) {
t.Skip()
}
cache := NewOGTagCache(target, config.OpenGraph{}, memory.New(context.Background()), TargetOptions{})
cache := NewOGTagCache(target, config.OpenGraph{}, memory.New(context.Background()))
u := &url.URL{Path: path, RawQuery: query}
result := cache.getTarget(u)
@@ -245,7 +245,7 @@ func FuzzExtractMetaTagInfo(f *testing.F) {
},
}
cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(context.Background()), TargetOptions{})
cache := NewOGTagCache("http://example.com", config.OpenGraph{}, memory.New(context.Background()))
// Should not panic
property, content := cache.extractMetaTagInfo(node)
@@ -298,7 +298,7 @@ func BenchmarkFuzzedGetTarget(b *testing.B) {
for _, input := range inputs {
b.Run(input.name, func(b *testing.B) {
cache := NewOGTagCache(input.target, config.OpenGraph{}, memory.New(context.Background()), TargetOptions{})
cache := NewOGTagCache(input.target, config.OpenGraph{}, memory.New(context.Background()))
u := &url.URL{Path: input.path, RawQuery: input.query}
b.ResetTimer()

View File

@@ -2,23 +2,15 @@ package ogtags
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"errors"
"fmt"
"math/big"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
@@ -53,7 +45,7 @@ func TestNewOGTagCache(t *testing.T) {
Enabled: tt.ogPassthrough,
TimeToLive: tt.ogTimeToLive,
ConsiderHost: false,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
if cache == nil {
t.Fatal("expected non-nil cache, got nil")
@@ -93,7 +85,7 @@ func TestNewOGTagCache_UnixSocket(t *testing.T) {
Enabled: true,
TimeToLive: 5 * time.Minute,
ConsiderHost: false,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
if cache == nil {
t.Fatal("expected non-nil cache, got nil")
@@ -178,7 +170,7 @@ func TestGetTarget(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
u := &url.URL{
Path: tt.path,
@@ -251,7 +243,7 @@ func TestIntegrationGetOGTags_UnixSocket(t *testing.T) {
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
// Create a dummy URL for the request (path and query matter)
testReqURL, _ := url.Parse("/some/page?query=1")
@@ -282,244 +274,3 @@ func TestIntegrationGetOGTags_UnixSocket(t *testing.T) {
t.Errorf("Expected cached OG tags %v, got %v", expectedTags, cachedTags)
}
}
func TestGetOGTagsWithTargetHostOverride(t *testing.T) {
originalHost := "example.test"
overrideHost := "backend.internal"
seenHosts := make(chan string, 10)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seenHosts <- r.Host
w.Header().Set("Content-Type", "text/html")
fmt.Fprintln(w, `<!DOCTYPE html><html><head><meta property="og:title" content="HostOverride" /></head><body>ok</body></html>`)
}))
defer ts.Close()
targetURL, err := url.Parse(ts.URL)
if err != nil {
t.Fatalf("failed to parse server URL: %v", err)
}
conf := config.OpenGraph{
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
}
t.Run("default host uses original", func(t *testing.T) {
cache := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{})
if _, err := cache.GetOGTags(t.Context(), targetURL, originalHost); err != nil {
t.Fatalf("GetOGTags failed: %v", err)
}
select {
case host := <-seenHosts:
if host != originalHost {
t.Fatalf("expected host %q, got %q", originalHost, host)
}
case <-time.After(time.Second):
t.Fatal("server did not receive request")
}
})
t.Run("override host respected", func(t *testing.T) {
cache := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{
Host: overrideHost,
})
if _, err := cache.GetOGTags(t.Context(), targetURL, originalHost); err != nil {
t.Fatalf("GetOGTags failed: %v", err)
}
select {
case host := <-seenHosts:
if host != overrideHost {
t.Fatalf("expected host %q, got %q", overrideHost, host)
}
case <-time.After(time.Second):
t.Fatal("server did not receive request")
}
})
}
func TestGetOGTagsWithInsecureSkipVerify(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintln(w, `<!DOCTYPE html><html><head><meta property="og:title" content="Self-Signed" /></head><body>hello</body></html>`)
})
ts := httptest.NewTLSServer(handler)
defer ts.Close()
parsedURL, err := url.Parse(ts.URL)
if err != nil {
t.Fatalf("failed to parse server URL: %v", err)
}
conf := config.OpenGraph{
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
}
// Without skip verify we should get a TLS error
cacheStrict := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{})
if _, err := cacheStrict.GetOGTags(t.Context(), parsedURL, parsedURL.Host); err == nil {
t.Fatal("expected TLS verification error without InsecureSkipVerify")
}
cacheSkip := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{
InsecureSkipVerify: true,
})
tags, err := cacheSkip.GetOGTags(t.Context(), parsedURL, parsedURL.Host)
if err != nil {
t.Fatalf("expected successful fetch with InsecureSkipVerify, got: %v", err)
}
if tags["og:title"] != "Self-Signed" {
t.Fatalf("expected og:title to be %q, got %q", "Self-Signed", tags["og:title"])
}
}
func TestGetOGTagsWithTargetSNI(t *testing.T) {
originalHost := "hecate.test"
conf := config.OpenGraph{
Enabled: true,
TimeToLive: time.Minute,
ConsiderHost: false,
}
t.Run("explicit SNI override", func(t *testing.T) {
expectedSNI := "backend.internal"
ts, recorder := newSNIServer(t, `<!DOCTYPE html><html><head><meta property="og:title" content="SNI Works" /></head><body>ok</body></html>`)
defer ts.Close()
targetURL, err := url.Parse(ts.URL)
if err != nil {
t.Fatalf("failed to parse server URL: %v", err)
}
cacheExplicit := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{
SNI: expectedSNI,
InsecureSkipVerify: true,
})
if _, err := cacheExplicit.GetOGTags(t.Context(), targetURL, originalHost); err != nil {
t.Fatalf("expected successful fetch with explicit SNI, got: %v", err)
}
if got := recorder.last(); got != expectedSNI {
t.Fatalf("expected server to see SNI %q, got %q", expectedSNI, got)
}
})
t.Run("auto SNI uses original host", func(t *testing.T) {
ts, recorder := newSNIServer(t, `<!DOCTYPE html><html><head><meta property="og:title" content="SNI Auto" /></head><body>ok</body></html>`)
defer ts.Close()
targetURL, err := url.Parse(ts.URL)
if err != nil {
t.Fatalf("failed to parse server URL: %v", err)
}
cacheAuto := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{
SNI: "auto",
InsecureSkipVerify: true,
})
if _, err := cacheAuto.GetOGTags(t.Context(), targetURL, originalHost); err != nil {
t.Fatalf("expected successful fetch with auto SNI, got: %v", err)
}
if got := recorder.last(); got != originalHost {
t.Fatalf("expected server to see SNI %q with auto, got %q", originalHost, got)
}
})
t.Run("default SNI uses backend host", func(t *testing.T) {
ts, recorder := newSNIServer(t, `<!DOCTYPE html><html><head><meta property="og:title" content="SNI Default" /></head><body>ok</body></html>`)
defer ts.Close()
targetURL, err := url.Parse(ts.URL)
if err != nil {
t.Fatalf("failed to parse server URL: %v", err)
}
cacheDefault := NewOGTagCache(ts.URL, conf, memory.New(t.Context()), TargetOptions{
InsecureSkipVerify: true,
})
if _, err := cacheDefault.GetOGTags(t.Context(), targetURL, originalHost); err != nil {
t.Fatalf("expected successful fetch without explicit SNI, got: %v", err)
}
wantSNI := ""
if net.ParseIP(targetURL.Hostname()) == nil {
wantSNI = targetURL.Hostname()
}
if got := recorder.last(); got != wantSNI {
t.Fatalf("expected default SNI %q, got %q", wantSNI, got)
}
})
}
func newSNIServer(t *testing.T, body string) (*httptest.Server, *sniRecorder) {
t.Helper()
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, body)
})
recorder := &sniRecorder{}
ts := httptest.NewUnstartedServer(handler)
cert := mustCertificateForHost(t, "sni.test")
ts.TLS = &tls.Config{
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
recorder.record(hello.ServerName)
return &cert, nil
},
}
ts.StartTLS()
return ts, recorder
}
func mustCertificateForHost(t *testing.T, host string) tls.Certificate {
t.Helper()
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("failed to generate key: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: host,
},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
BasicConstraintsValid: true,
DNSNames: []string{host},
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("failed to create certificate: %v", err)
}
return tls.Certificate{
Certificate: [][]byte{der},
PrivateKey: priv,
}
}
type sniRecorder struct {
mu sync.Mutex
names []string
}
func (r *sniRecorder) record(name string) {
r.mu.Lock()
defer r.mu.Unlock()
r.names = append(r.names, name)
}
func (r *sniRecorder) last() string {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.names) == 0 {
return ""
}
return r.names[len(r.names)-1]
}

View File

@@ -18,7 +18,7 @@ func TestExtractOGTags(t *testing.T) {
Enabled: false,
ConsiderHost: false,
TimeToLive: time.Minute,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
// Manually set approved tags/prefixes based on the user request for clarity
testCache.approvedTags = []string{"description"}
testCache.approvedPrefixes = []string{"og:"}
@@ -199,7 +199,7 @@ func TestExtractMetaTagInfo(t *testing.T) {
Enabled: false,
ConsiderHost: false,
TimeToLive: time.Minute,
}, memory.New(t.Context()), TargetOptions{})
}, memory.New(t.Context()))
testCache.approvedTags = []string{"description"}
testCache.approvedPrefixes = []string{"og:"}

View File

@@ -1,42 +0,0 @@
package ogtags
import (
"crypto/tls"
"net/http"
)
// clientForSNI returns a cached client for the given server name, creating one if needed.
func (c *OGTagCache) clientForSNI(serverName string) *http.Client {
if !c.targetSNIAuto || serverName == "" {
return c.client
}
c.transportMu.RLock()
cli, ok := c.sniClients[serverName]
c.transportMu.RUnlock()
if ok {
return cli
}
c.transportMu.Lock()
defer c.transportMu.Unlock()
if cli, ok := c.sniClients[serverName]; ok {
return cli
}
tr := c.transport.Clone()
if tr.TLSClientConfig == nil {
tr.TLSClientConfig = &tls.Config{}
}
tr.TLSClientConfig.ServerName = serverName
if c.insecureSkipVerify {
tr.TLSClientConfig.InsecureSkipVerify = true
}
cli = &http.Client{
Timeout: httpTimeout,
Transport: tr,
}
c.sniClients[serverName] = cli
return cli
}

View File

@@ -27,30 +27,27 @@ import (
)
type Options struct {
Next http.Handler
Policy *policy.ParsedConfig
Target string
TargetHost string
TargetSNI string
TargetInsecureSkipVerify bool
CookieDynamicDomain bool
CookieDomain string
CookieExpiration time.Duration
CookiePartitioned bool
BasePrefix string
WebmasterEmail string
RedirectDomains []string
ED25519PrivateKey ed25519.PrivateKey
HS512Secret []byte
StripBasePrefix bool
OpenGraph config.OpenGraph
ServeRobotsTXT bool
CookieSecure bool
CookieSameSite http.SameSite
Logger *slog.Logger
PublicUrl string
JWTRestrictionHeader string
DifficultyInJWT bool
Next http.Handler
Policy *policy.ParsedConfig
Logger *slog.Logger
OpenGraph config.OpenGraph
PublicUrl string
CookieDomain string
JWTRestrictionHeader string
BasePrefix string
WebmasterEmail string
Target string
RedirectDomains []string
ED25519PrivateKey ed25519.PrivateKey
HS512Secret []byte
CookieExpiration time.Duration
CookieSameSite http.SameSite
ServeRobotsTXT bool
CookieSecure bool
StripBasePrefix bool
CookiePartitioned bool
CookieDynamicDomain bool
DifficultyInJWT bool
}
func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty int) (*policy.ParsedConfig, error) {
@@ -119,13 +116,9 @@ func New(opts Options) (*Server, error) {
hs512Secret: opts.HS512Secret,
policy: opts.Policy,
opts: opts,
OGTags: ogtags.NewOGTagCache(opts.Target, opts.Policy.OpenGraph, opts.Policy.Store, ogtags.TargetOptions{
Host: opts.TargetHost,
SNI: opts.TargetSNI,
InsecureSkipVerify: opts.TargetInsecureSkipVerify,
}),
store: opts.Policy.Store,
logger: opts.Logger,
OGTags: ogtags.NewOGTagCache(opts.Target, opts.Policy.OpenGraph, opts.Policy.Store),
store: opts.Policy.Store,
logger: opts.Logger,
}
mux := http.NewServeMux()

View File

@@ -62,14 +62,11 @@ type BotConfig struct {
Expression *ExpressionOrList `json:"expression,omitempty" yaml:"expression,omitempty"`
Challenge *ChallengeRules `json:"challenge,omitempty" yaml:"challenge,omitempty"`
Weight *Weight `json:"weight,omitempty" yaml:"weight,omitempty"`
// Thoth features
GeoIP *GeoIP `json:"geoip,omitempty"`
ASNs *ASNs `json:"asns,omitempty"`
Name string `json:"name" yaml:"name"`
Action Rule `json:"action" yaml:"action"`
RemoteAddr []string `json:"remote_addresses,omitempty" yaml:"remote_addresses,omitempty"`
GeoIP *GeoIP `json:"geoip,omitempty"`
ASNs *ASNs `json:"asns,omitempty"`
Name string `json:"name" yaml:"name"`
Action Rule `json:"action" yaml:"action"`
RemoteAddr []string `json:"remote_addresses,omitempty" yaml:"remote_addresses,omitempty"`
}
func (b BotConfig) Zero() bool {

View File

@@ -7,7 +7,6 @@ import (
"fmt"
"time"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/store"
valkey "github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/maintnotifications"
@@ -17,84 +16,26 @@ func init() {
store.Register("valkey", Factory{})
}
// Errors kept as-is so other code/tests still pass.
var (
ErrNoURL = errors.New("valkey.Config: no URL defined")
ErrBadURL = errors.New("valkey.Config: URL is invalid")
// Sentinel validation errors
ErrSentinelMasterNameRequired = errors.New("valkey.Sentinel: masterName is required")
ErrSentinelAddrRequired = errors.New("valkey.Sentinel: addr is required")
ErrSentinelAddrEmpty = errors.New("valkey.Sentinel: addr cannot be empty")
)
// Config is what Anubis unmarshals from the "parameters" JSON.
type Config struct {
URL string `json:"url"`
Cluster bool `json:"cluster,omitempty"`
Sentinel *Sentinel `json:"sentinel,omitempty"`
}
func (c Config) Valid() error {
var errs []error
if c.URL == "" && c.Sentinel == nil {
errs = append(errs, ErrNoURL)
if c.URL == "" {
return ErrNoURL
}
// Validate URL only if provided
if c.URL != "" {
if _, err := valkey.ParseURL(c.URL); err != nil {
errs = append(errs, fmt.Errorf("%w: %v", ErrBadURL, err))
}
}
if c.Sentinel != nil {
if err := c.Sentinel.Valid(); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
}
type Sentinel struct {
MasterName string `json:"masterName"`
Addr internal.ListOr[string] `json:"addr"`
ClientName string `json:"clientName,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
}
func (s Sentinel) Valid() error {
var errs []error
if s.MasterName == "" {
errs = append(errs, ErrSentinelMasterNameRequired)
}
if len(s.Addr) == 0 {
errs = append(errs, ErrSentinelAddrRequired)
} else {
// Check if all addresses in the list are empty
allEmpty := true
for _, addr := range s.Addr {
if addr != "" {
allEmpty = false
break
}
}
if allEmpty {
errs = append(errs, ErrSentinelAddrEmpty)
}
}
if len(errs) > 0 {
return errors.Join(errs...)
// Just validate that it's a valid Redis URL.
if _, err := valkey.ParseURL(c.URL); err != nil {
return fmt.Errorf("%w: %v", ErrBadURL, err)
}
return nil
@@ -127,15 +68,14 @@ func (Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface
return nil, err
}
opts, err := valkey.ParseURL(cfg.URL)
if err != nil {
return nil, fmt.Errorf("valkey.Factory: %w", err)
}
var client redisClient
switch {
case cfg.Cluster:
opts, err := valkey.ParseURL(cfg.URL)
if err != nil {
return nil, fmt.Errorf("valkey.Factory: %w", err)
}
if cfg.Cluster {
// Cluster mode: use the parsed Addr as the seed node.
clusterOpts := &valkey.ClusterOptions{
Addrs: []string{opts.Addr},
@@ -146,23 +86,7 @@ func (Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface
},
}
client = valkey.NewClusterClient(clusterOpts)
case cfg.Sentinel != nil:
opts := &valkey.FailoverOptions{
MasterName: cfg.Sentinel.MasterName,
SentinelAddrs: cfg.Sentinel.Addr,
SentinelUsername: cfg.Sentinel.Username,
SentinelPassword: cfg.Sentinel.Password,
Username: cfg.Sentinel.Username,
Password: cfg.Sentinel.Password,
ClientName: cfg.Sentinel.ClientName,
}
client = valkey.NewFailoverClusterClient(opts)
default:
opts, err := valkey.ParseURL(cfg.URL)
if err != nil {
return nil, fmt.Errorf("valkey.Factory: %w", err)
}
} else {
opts.MaintNotificationsConfig = &maintnotifications.Config{
Mode: maintnotifications.ModeDisabled,
}

View File

@@ -2,7 +2,6 @@ package valkey
import (
"encoding/json"
"errors"
"os"
"testing"
@@ -46,86 +45,3 @@ func TestImpl(t *testing.T) {
storetest.Common(t, Factory{}, json.RawMessage(data))
}
func TestFactoryValid(t *testing.T) {
tests := []struct {
name string
jsonData string
expectError error
}{
{
name: "empty config",
jsonData: `{}`,
expectError: ErrNoURL,
},
{
name: "valid URL only",
jsonData: `{"url": "redis://localhost:6379"}`,
expectError: nil,
},
{
name: "invalid URL",
jsonData: `{"url": "invalid-url"}`,
expectError: ErrBadURL,
},
{
name: "valid sentinel config",
jsonData: `{"sentinel": {"masterName": "mymaster", "addr": ["localhost:26379"], "password": "mypass"}}`,
expectError: nil,
},
{
name: "sentinel missing masterName",
jsonData: `{"sentinel": {"addr": ["localhost:26379"], "password": "mypass"}}`,
expectError: ErrSentinelMasterNameRequired,
},
{
name: "sentinel missing addr",
jsonData: `{"sentinel": {"masterName": "mymaster", "password": "mypass"}}`,
expectError: ErrSentinelAddrRequired,
},
{
name: "sentinel empty addr",
jsonData: `{"sentinel": {"masterName": "mymaster", "addr": [""], "password": "mypass"}}`,
expectError: ErrSentinelAddrEmpty,
},
{
name: "sentinel missing password",
jsonData: `{"sentinel": {"masterName": "mymaster", "addr": ["localhost:26379"]}}`,
expectError: nil,
},
{
name: "sentinel with optional fields",
jsonData: `{"sentinel": {"masterName": "mymaster", "addr": ["localhost:26379"], "password": "mypass", "clientName": "myclient", "username": "myuser"}}`,
expectError: nil,
},
{
name: "sentinel single address (not array)",
jsonData: `{"sentinel": {"masterName": "mymaster", "addr": "localhost:26379", "password": "mypass"}}`,
expectError: nil,
},
{
name: "sentinel mixed empty and valid addresses",
jsonData: `{"sentinel": {"masterName": "mymaster", "addr": ["", "localhost:26379", ""], "password": "mypass"}}`,
expectError: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
factory := Factory{}
err := factory.Valid(json.RawMessage(tt.jsonData))
if tt.expectError == nil {
if err != nil {
t.Errorf("expected no error, got: %v", err)
}
} else {
if err == nil {
t.Errorf("expected error %v, got nil", tt.expectError)
} else if !errors.Is(err, tt.expectError) {
t.Errorf("expected error %v, got: %v", tt.expectError, err)
}
}
})
}
}

216
package-lock.json generated
View File

@@ -15,7 +15,7 @@
"devDependencies": {
"cssnano": "^7.1.2",
"cssnano-preset-advanced": "^7.0.10",
"esbuild": "^0.27.0",
"esbuild": "^0.25.12",
"playwright": "^1.52.0",
"postcss-cli": "^11.0.1",
"postcss-import": "^16.1.1",
@@ -62,9 +62,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz",
"integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
@@ -79,9 +79,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz",
"integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
@@ -96,9 +96,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz",
"integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
@@ -113,9 +113,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz",
"integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
@@ -130,9 +130,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz",
"integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
@@ -147,9 +147,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz",
"integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
@@ -164,9 +164,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz",
"integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
@@ -181,9 +181,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz",
"integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
@@ -198,9 +198,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz",
"integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
@@ -215,9 +215,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz",
"integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
@@ -232,9 +232,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz",
"integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
@@ -249,9 +249,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz",
"integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
@@ -266,9 +266,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz",
"integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
@@ -283,9 +283,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz",
"integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
@@ -300,9 +300,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz",
"integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
@@ -317,9 +317,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz",
"integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
@@ -334,9 +334,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz",
"integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
@@ -351,9 +351,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz",
"integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
@@ -368,9 +368,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz",
"integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
@@ -385,9 +385,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz",
"integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
@@ -402,9 +402,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz",
"integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
@@ -419,9 +419,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz",
"integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
@@ -436,9 +436,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz",
"integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
@@ -453,9 +453,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz",
"integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
@@ -470,9 +470,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz",
"integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
@@ -487,9 +487,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz",
"integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
@@ -1155,9 +1155,9 @@
}
},
"node_modules/esbuild": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz",
"integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==",
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1168,32 +1168,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.0",
"@esbuild/android-arm": "0.27.0",
"@esbuild/android-arm64": "0.27.0",
"@esbuild/android-x64": "0.27.0",
"@esbuild/darwin-arm64": "0.27.0",
"@esbuild/darwin-x64": "0.27.0",
"@esbuild/freebsd-arm64": "0.27.0",
"@esbuild/freebsd-x64": "0.27.0",
"@esbuild/linux-arm": "0.27.0",
"@esbuild/linux-arm64": "0.27.0",
"@esbuild/linux-ia32": "0.27.0",
"@esbuild/linux-loong64": "0.27.0",
"@esbuild/linux-mips64el": "0.27.0",
"@esbuild/linux-ppc64": "0.27.0",
"@esbuild/linux-riscv64": "0.27.0",
"@esbuild/linux-s390x": "0.27.0",
"@esbuild/linux-x64": "0.27.0",
"@esbuild/netbsd-arm64": "0.27.0",
"@esbuild/netbsd-x64": "0.27.0",
"@esbuild/openbsd-arm64": "0.27.0",
"@esbuild/openbsd-x64": "0.27.0",
"@esbuild/openharmony-arm64": "0.27.0",
"@esbuild/sunos-x64": "0.27.0",
"@esbuild/win32-arm64": "0.27.0",
"@esbuild/win32-ia32": "0.27.0",
"@esbuild/win32-x64": "0.27.0"
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/escalade": {

View File

@@ -20,7 +20,7 @@
"devDependencies": {
"cssnano": "^7.1.2",
"cssnano-preset-advanced": "^7.0.10",
"esbuild": "^0.27.0",
"esbuild": "^0.25.12",
"playwright": "^1.52.0",
"postcss-cli": "^11.0.1",
"postcss-import": "^16.1.1",