Compare commits

...

4 Commits

Author SHA1 Message Date
Xe Iaso
208ceca723 chore: move checker package to top level
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-07-01 23:58:42 +00:00
Xe Iaso
dc0dde3053 chore: start refactor of checkers into separate packages
Signed-off-by: Xe Iaso <me@xeiaso.net>
2025-07-01 22:27:01 +00:00
Victor Fernandes
292c470ada Set cookies to have the Secure flag default to true (#739)
* Set Cookies to use the Secure Flag and default SameSite to None

* Add secure flag test

* Updated changelog and documentation for secure flag option
2025-06-30 14:58:31 -04:00
Rafael Fontenelle
12453fdc00 Fix translations in pt-BR.json (#729)
Signed-off-by: Rafael Fontenelle <rffontenelle@users.noreply.github.com>
2025-06-30 14:14:24 -04:00
20 changed files with 433 additions and 23 deletions

View File

@@ -51,6 +51,7 @@ var (
cookiePrefix = flag.String("cookie-prefix", "techaro.lol-anubis", "prefix for browser cookies created by Anubis")
cookiePartitioned = flag.Bool("cookie-partitioned", false, "if true, sets the partitioned flag on Anubis cookies, enabling CHIPS support")
hs512Secret = flag.String("hs512-secret", "", "secret used to sign JWTs, uses ed25519 if not set")
cookieSecure = flag.Bool("cookie-secure", true, "if true, sets the secure flag on Anubis cookies")
ed25519PrivateKeyHex = flag.String("ed25519-private-key-hex", "", "private key used to sign JWTs, if not set a random one will be assigned")
ed25519PrivateKeyHexFile = flag.String("ed25519-private-key-hex-file", "", "file name containing value for ed25519-private-key-hex")
metricsBind = flag.String("metrics-bind", ":9090", "network address to bind metrics to")
@@ -403,6 +404,7 @@ func main() {
Target: *target,
WebmasterEmail: *webmasterEmail,
OpenGraph: policy.OpenGraph,
CookieSecure: *cookieSecure,
})
if err != nil {
log.Fatalf("can't construct libanubis.Server: %v", err)

View File

@@ -10,6 +10,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
<!-- This changes the project to: -->
- Add `COOKIE_SECURE` option to set the cookie [Secure flag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies#block_access_to_your_cookies)
- Sets cookie defaults to use [SameSite: None](https://web.dev/articles/samesite-cookies-explained)
- Determine the `BIND_NETWORK`/`--bind-network` value from the bind address ([#677](https://github.com/TecharoHQ/anubis/issues/677)).
- Implement localization system. Find locale files in lib/localization/locales/.

View File

@@ -67,6 +67,7 @@ Anubis uses these environment variables for configuration:
| `COOKIE_DYNAMIC_DOMAIN` | false | If set to true, automatically set cookie domain fields based on the hostname of the request. EG: if you are making a request to `anubis.techaro.lol`, the Anubis cookie will be valid for any subdomain of `techaro.lol`. |
| `COOKIE_EXPIRATION_TIME` | `168h` | The amount of time the authorization cookie is valid for. |
| `COOKIE_PARTITIONED` | `false` | If set to `true`, enables the [partitioned (CHIPS) flag](https://developers.google.com/privacy-sandbox/cookies/chips), meaning that Anubis inside an iframe has a different set of cookies than the domain hosting the iframe. |
| `COOKIE_SECURE` | `true` | If set to `true`, enables the [Secure flag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies#block_access_to_your_cookies), meaning that the cookies will only be transmitted over HTTPS. If Anubis is used in an unsecure context (plain HTTP), this will be need to be set to false |
| `DIFFICULTY` | `4` | The difficulty of the challenge, or the number of leading zeroes that must be in successful responses. |
| `ED25519_PRIVATE_KEY_HEX` | unset | The hex-encoded ed25519 private key used to sign Anubis responses. If this is not set, Anubis will generate one for you. This should be exactly 64 characters long. See below for details. |
| `ED25519_PRIVATE_KEY_HEX_FILE` | unset | Path to a file containing the hex-encoded ed25519 private key. Only one of this or its sister option may be set. |

View File

@@ -10,7 +10,7 @@ import (
"time"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/policy/checker"
"github.com/TecharoHQ/anubis/lib/checker"
iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1"
)

View File

@@ -6,7 +6,7 @@ import (
"testing"
"github.com/TecharoHQ/anubis/internal/thoth"
"github.com/TecharoHQ/anubis/lib/policy/checker"
"github.com/TecharoHQ/anubis/lib/checker"
iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1"
)

View File

@@ -9,7 +9,7 @@ import (
"strings"
"time"
"github.com/TecharoHQ/anubis/lib/policy/checker"
"github.com/TecharoHQ/anubis/lib/checker"
iptoasnv1 "github.com/TecharoHQ/thoth-proto/gen/techaro/thoth/iptoasn/v1"
)

View File

@@ -6,7 +6,7 @@ import (
"testing"
"github.com/TecharoHQ/anubis/internal/thoth"
"github.com/TecharoHQ/anubis/lib/policy/checker"
"github.com/TecharoHQ/anubis/lib/checker"
)
var _ checker.Impl = &thoth.GeoIPChecker{}

View File

@@ -26,14 +26,17 @@ import (
"github.com/TecharoHQ/anubis/internal/dnsbl"
"github.com/TecharoHQ/anubis/internal/ogtags"
"github.com/TecharoHQ/anubis/lib/challenge"
"github.com/TecharoHQ/anubis/lib/checker"
"github.com/TecharoHQ/anubis/lib/localization"
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/checker"
"github.com/TecharoHQ/anubis/lib/policy/config"
// challenge implementations
_ "github.com/TecharoHQ/anubis/lib/challenge/metarefresh"
_ "github.com/TecharoHQ/anubis/lib/challenge/proofofwork"
// checker implementations
_ "github.com/TecharoHQ/anubis/lib/checker/remoteaddress"
)
var (

View File

@@ -262,6 +262,7 @@ func TestCookieSettings(t *testing.T) {
CookieDomain: "127.0.0.1",
CookiePartitioned: true,
CookieSecure: true,
CookieExpiration: anubis.CookieDefaultExpirationTime,
})
@@ -309,6 +310,10 @@ func TestCookieSettings(t *testing.T) {
if ckie.Partitioned != srv.opts.CookiePartitioned {
t.Errorf("wanted partitioned flag %v, got: %v", srv.opts.CookiePartitioned, ckie.Partitioned)
}
if ckie.Secure != srv.opts.CookieSecure {
t.Errorf("wanted secure flag %v, got: %v", srv.opts.CookieSecure, ckie.Secure)
}
}
func TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) {

View File

@@ -10,7 +10,7 @@ import (
)
type Impl interface {
Check(*http.Request) (bool, error)
Check(*http.Request) (matches bool, err error)
Hash() string
}

42
lib/checker/registry.go Normal file
View File

@@ -0,0 +1,42 @@
package checker
import (
"encoding/json"
"sort"
"sync"
)
type Factory interface {
ValidateConfig(json.RawMessage) error
Create(json.RawMessage) (Impl, error)
}
var (
registry map[string]Factory = map[string]Factory{}
regLock sync.RWMutex
)
func Register(name string, factory Factory) {
regLock.Lock()
defer regLock.Unlock()
registry[name] = factory
}
func Get(name string) (Factory, bool) {
regLock.RLock()
defer regLock.RUnlock()
result, ok := registry[name]
return result, ok
}
func Methods() []string {
regLock.RLock()
defer regLock.RUnlock()
var result []string
for method := range registry {
result = append(result, method)
}
sort.Strings(result)
return result
}

View File

@@ -0,0 +1,106 @@
package remoteaddress
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/netip"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/checker"
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/gaissmai/bart"
)
var (
ErrNoRemoteAddresses = errors.New("remoteaddress: no remote addresses defined")
)
func init() {
checker.Register("remote_address", Factory{})
}
type Factory struct{}
func (Factory) ValidateConfig(inp json.RawMessage) error {
var fc fileConfig
if err := json.Unmarshal([]byte(inp), &fc); err != nil {
return fmt.Errorf("%w: %w", config.ErrUnparseableConfig, err)
}
if err := fc.Valid(); err != nil {
return err
}
return nil
}
func (Factory) Create(inp json.RawMessage) (checker.Impl, error) {
c := struct {
RemoteAddr []netip.Prefix `json:"remote_addresses,omitempty" yaml:"remote_addresses,omitempty"`
}{}
if err := json.Unmarshal([]byte(inp), &c); err != nil {
return nil, fmt.Errorf("%w: %w", config.ErrUnparseableConfig, err)
}
table := new(bart.Lite)
for _, cidr := range c.RemoteAddr {
table.Insert(cidr)
}
return &Impl{
prefixTable: table,
hash: internal.FastHash(string(inp)),
}, nil
}
type fileConfig struct {
RemoteAddr []string `json:"remote_addresses,omitempty" yaml:"remote_addresses,omitempty"`
}
func (fc fileConfig) Valid() error {
var errs []error
if len(fc.RemoteAddr) == 0 {
errs = append(errs, ErrNoRemoteAddresses)
}
for _, cidr := range fc.RemoteAddr {
if _, err := netip.ParsePrefix(cidr); err != nil {
errs = append(errs, fmt.Errorf("%w: cidr %q is invalid: %w", config.ErrInvalidCIDR, cidr, err))
}
}
if len(errs) != 0 {
return fmt.Errorf("%w: %w", policy.ErrMisconfiguration, errors.Join(errs...))
}
return nil
}
type Impl struct {
prefixTable *bart.Lite
hash string
}
func (rac *Impl) Check(r *http.Request) (bool, error) {
host := r.Header.Get("X-Real-Ip")
if host == "" {
return false, fmt.Errorf("%w: header X-Real-Ip is not set", policy.ErrMisconfiguration)
}
addr, err := netip.ParseAddr(host)
if err != nil {
return false, fmt.Errorf("%w: %s is not an IP address: %w", policy.ErrMisconfiguration, host, err)
}
return rac.prefixTable.Contains(addr), nil
}
func (rac *Impl) Hash() string {
return rac.hash
}

View File

@@ -0,0 +1,238 @@
package remoteaddress
import (
_ "embed"
"encoding/json"
"errors"
"net/http"
"net/netip"
"strings"
"testing"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/checker"
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/gaissmai/bart"
)
func TestFactoryIsCheckerFactory(t *testing.T) {
if _, ok := (any(Factory{})).(checker.Factory); !ok {
t.Fatal("Factory is not an instance of checker.Factory")
}
}
func TestFactoryValidateConfig(t *testing.T) {
f := Factory{}
for _, tt := range []struct {
name string
data []byte
err error
}{
{
name: "basic valid",
data: []byte(`{
"remote_addresses": [
"1.1.1.1/32"
]
}`),
},
{
name: "not json",
data: []byte(`]`),
err: config.ErrUnparseableConfig,
},
{
name: "no cidr",
data: []byte(`{
"remote_addresses": []
}`),
err: ErrNoRemoteAddresses,
},
{
name: "bad cidr",
data: []byte(`{
"remote_addresses": [
"according to all laws of aviation"
]
}`),
err: config.ErrInvalidCIDR,
},
} {
t.Run(tt.name, func(t *testing.T) {
data := json.RawMessage(tt.data)
if err := f.ValidateConfig(data); !errors.Is(err, tt.err) {
t.Logf("want: %v", tt.err)
t.Logf("got: %v", err)
t.Fatal("validation didn't do what was expected")
}
})
}
}
func TestFactoryCreate(t *testing.T) {
f := Factory{}
for _, tt := range []struct {
name string
data []byte
err error
ip string
match bool
}{
{
name: "basic valid",
data: []byte(`{
"remote_addresses": [
"1.1.1.1/32"
]
}`),
ip: "1.1.1.1",
match: true,
},
{
name: "bad cidr",
data: []byte(`{
"remote_addresses": [
"according to all laws of aviation"
]
}`),
err: config.ErrUnparseableConfig,
},
} {
t.Run(tt.name, func(t *testing.T) {
data := json.RawMessage(tt.data)
impl, err := f.Create(data)
if !errors.Is(err, tt.err) {
t.Logf("want: %v", tt.err)
t.Logf("got: %v", err)
t.Fatal("creation didn't do what was expected")
}
if tt.err != nil {
return
}
r, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatalf("can't make request: %v", err)
}
if tt.ip != "" {
r.Header.Add("X-Real-Ip", tt.ip)
}
match, err := impl.Check(r)
if tt.match != match {
t.Errorf("match: %v, wanted: %v", match, tt.match)
}
if err != nil && tt.err != nil && !errors.Is(err, tt.err) {
t.Errorf("err: %v, wanted: %v", err, tt.err)
}
if impl.Hash() == "" {
t.Error("hash method returns empty string")
}
})
}
}
func racFromCidrs(t *testing.T, inp []string) *Impl {
t.Helper()
var result Impl
result.prefixTable = new(bart.Lite)
result.hash = internal.FastHash(strings.Join(inp, ","))
for _, cidr := range inp {
pfx, err := netip.ParsePrefix(cidr)
if err != nil {
t.Errorf("prefix %q is invalid: %v", cidr, err)
continue
}
result.prefixTable.Insert(pfx)
}
return &result
}
func TestRemoteAddrChecker(t *testing.T) {
for _, tt := range []struct {
err error
name string
ip string
cidrs []string
ok bool
}{
{
name: "match_ipv4",
cidrs: []string{"0.0.0.0/0"},
ip: "1.1.1.1",
ok: true,
err: nil,
},
{
name: "match_ipv6",
cidrs: []string{"::/0"},
ip: "cafe:babe::",
ok: true,
err: nil,
},
{
name: "not_match_ipv4",
cidrs: []string{"1.1.1.1/32"},
ip: "1.1.1.2",
ok: false,
err: nil,
},
{
name: "not_match_ipv6",
cidrs: []string{"cafe:babe::/128"},
ip: "cafe:babe:4::/128",
ok: false,
err: nil,
},
{
name: "no_ip_set",
cidrs: []string{"::/0"},
ok: false,
err: policy.ErrMisconfiguration,
},
{
name: "invalid_ip",
cidrs: []string{"::/0"},
ip: "According to all natural laws of aviation",
ok: false,
err: policy.ErrMisconfiguration,
},
} {
t.Run(tt.name, func(t *testing.T) {
rac := racFromCidrs(t, tt.cidrs)
r, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatalf("can't make request: %v", err)
}
if tt.ip != "" {
r.Header.Add("X-Real-Ip", tt.ip)
}
ok, err := rac.Check(r)
if tt.ok != ok {
t.Errorf("ok: %v, wanted: %v", ok, tt.ok)
}
if err != nil && tt.err != nil && !errors.Is(err, tt.err) {
t.Errorf("err: %v, wanted: %v", err, tt.err)
}
})
}
}

View File

@@ -44,6 +44,7 @@ type Options struct {
StripBasePrefix bool
OpenGraph config.OpenGraph
ServeRobotsTXT bool
CookieSecure bool
}
func LoadPoliciesOrDefault(ctx context.Context, fname string, defaultDifficulty int) (*policy.ParsedConfig, error) {

View File

@@ -23,10 +23,11 @@ import (
var domainMatchRegexp = regexp.MustCompile(`^((xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$`)
type CookieOpts struct {
Value string
Host string
Path string
Name string
Value string
Host string
Path string
Name string
Expiry time.Duration
}
func (s *Server) SetCookie(w http.ResponseWriter, cookieOpts CookieOpts) {
@@ -45,12 +46,17 @@ func (s *Server) SetCookie(w http.ResponseWriter, cookieOpts CookieOpts) {
}
}
if cookieOpts.Expiry == 0 {
cookieOpts.Expiry = s.opts.CookieExpiration
}
http.SetCookie(w, &http.Cookie{
Name: name,
Value: cookieOpts.Value,
Expires: time.Now().Add(s.opts.CookieExpiration),
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(cookieOpts.Expiry),
SameSite: http.SameSiteNoneMode,
Domain: domain,
Secure: s.opts.CookieSecure,
Partitioned: s.opts.CookiePartitioned,
Path: path,
})
@@ -77,9 +83,10 @@ func (s *Server) ClearCookie(w http.ResponseWriter, cookieOpts CookieOpts) {
Value: "",
MaxAge: -1,
Expires: time.Now().Add(-1 * time.Minute),
SameSite: http.SameSiteLaxMode,
SameSite: http.SameSiteNoneMode,
Partitioned: s.opts.CookiePartitioned,
Domain: domain,
Secure: s.opts.CookieSecure,
Path: path,
})
}
@@ -132,11 +139,12 @@ func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request, rule *polic
}
}
http.SetCookie(w, &http.Cookie{
Name: anubis.TestCookieName,
Value: challengeStr,
Expires: time.Now().Add(30 * time.Minute),
Path: "/",
s.SetCookie(w, CookieOpts{
Value: challengeStr,
Host: r.Host,
Path: "/",
Name: anubis.TestCookieName,
Expiry: 30 * time.Minute,
})
impl, ok := challenge.Get(rule.Challenge.Algorithm)

View File

@@ -27,10 +27,10 @@
"static_check_endpoint": "Este é apenas um ponto de verificação para seu proxy reverso usar.",
"authorization_required": "Autorização necessária",
"cookies_disabled": "Seu navegador está configurado para desabilitar cookies. O Anubis requer cookies para o interesse legítimo de garantir que você seja um cliente válido. Habilite os cookies para este domínio.",
"access_denied": "Acesso negado: código de errado",
"access_denied": "Acesso negado: código de erro",
"dronebl_entry": "DroneBL relatou uma entrada",
"see_dronebl_lookup": "consulte",
"internal_server_error": "Erro interno do servidor: o administrador configurou incorretamente o Anubis. Entre em contato com o administrador e peça para ele procurar os logs em torno dele.",
"internal_server_error": "Erro interno do servidor: o administrador configurou incorretamente o Anubis. Entre em contato com o administrador e peça para analisar os logs relacionados.",
"invalid_redirect": "Redirecionamento inválido",
"redirect_not_parseable": "URL de redirecionamento não analisável",
"redirect_domain_not_allowed": "Domínio de redirecionamento não permitido",

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/policy/checker"
"github.com/TecharoHQ/anubis/lib/checker"
"github.com/TecharoHQ/anubis/lib/policy/config"
)

View File

@@ -9,7 +9,7 @@ import (
"strings"
"github.com/TecharoHQ/anubis/internal"
"github.com/TecharoHQ/anubis/lib/policy/checker"
"github.com/TecharoHQ/anubis/lib/checker"
"github.com/gaissmai/bart"
)

View File

@@ -31,6 +31,7 @@ var (
ErrCantSetBotAndImportValuesAtOnce = errors.New("config.BotOrImport: can't set bot rules and import values at the same time")
ErrMustSetBotOrImportRules = errors.New("config.BotOrImport: rule definition is invalid, you must set either bot rules or an import statement, not both")
ErrStatusCodeNotValid = errors.New("config.StatusCode: status code not valid, must be between 100 and 599")
ErrUnparseableConfig = errors.New("config: can't parse configuration file")
)
type Rule string

View File

@@ -9,7 +9,7 @@ import (
"sync/atomic"
"github.com/TecharoHQ/anubis/internal/thoth"
"github.com/TecharoHQ/anubis/lib/policy/checker"
"github.com/TecharoHQ/anubis/lib/checker"
"github.com/TecharoHQ/anubis/lib/policy/config"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"