mirror of
https://github.com/TecharoHQ/anubis.git
synced 2026-04-08 09:38:45 +00:00
Compare commits
1 Commits
Xe/store-s
...
Xe/fix-for
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90a914ff26 |
3
.github/actions/spelling/allow.txt
vendored
3
.github/actions/spelling/allow.txt
vendored
@@ -5,5 +5,4 @@ ubuntu
|
||||
workarounds
|
||||
rjack
|
||||
msgbox
|
||||
xeact
|
||||
ABee
|
||||
xeact
|
||||
1
.github/actions/spelling/excludes.txt
vendored
1
.github/actions/spelling/excludes.txt
vendored
@@ -88,7 +88,6 @@
|
||||
^docs/manifest/.*$
|
||||
^docs/static/\.nojekyll$
|
||||
^lib/policy/config/testdata/bad/unparseable\.json$
|
||||
^internal/glob/glob_test.go$
|
||||
ignore$
|
||||
robots.txt
|
||||
^lib/localization/locales/.*\.json$
|
||||
|
||||
5
.github/actions/spelling/expect.txt
vendored
5
.github/actions/spelling/expect.txt
vendored
@@ -140,7 +140,6 @@ headermap
|
||||
healthcheck
|
||||
healthz
|
||||
hec
|
||||
Hetzner
|
||||
hmc
|
||||
homelab
|
||||
hostable
|
||||
@@ -238,6 +237,7 @@ pki
|
||||
podkova
|
||||
podman
|
||||
poststart
|
||||
poxied
|
||||
prebaked
|
||||
privkey
|
||||
promauto
|
||||
@@ -250,6 +250,7 @@ pwuser
|
||||
qualys
|
||||
qwant
|
||||
qwantbot
|
||||
QWEN
|
||||
rac
|
||||
rawler
|
||||
rcvar
|
||||
@@ -281,6 +282,7 @@ shirou
|
||||
Sidetrade
|
||||
simprint
|
||||
sitemap
|
||||
Slackware
|
||||
sls
|
||||
Smartphone
|
||||
sni
|
||||
@@ -358,7 +360,6 @@ XOriginal
|
||||
XReal
|
||||
yae
|
||||
YAMLTo
|
||||
Yda
|
||||
yeet
|
||||
yeetfile
|
||||
yourdomain
|
||||
|
||||
@@ -13,69 +13,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
<!-- This changes the project to: -->
|
||||
|
||||
- Add the [`s3api` storage backend](./admin/policies.mdx#s3api) to allow Anubis to use S3 API compatible object storage as its storage backend.
|
||||
|
||||
## v1.22.0: Yda Hext
|
||||
|
||||
> Someone has to make an effort at reconciliation if these conflicts are ever going to end.
|
||||
|
||||
In this release, we finally fix the odd number of CPU cores bug, pave the way for lighter weight challenges, make Anubis more adaptable, and more.
|
||||
|
||||
### Big ticket items
|
||||
|
||||
#### Proof of React challenge
|
||||
|
||||
A new ["proof of React"](./admin/configuration/challenges/preact.mdx) has been added. It runs a simple app in React that has several chained hooks. It is much more lightweight than the proof of work check.
|
||||
|
||||
#### Smaller features
|
||||
|
||||
- The [`segments`](./admin/configuration/expressions.mdx#segments) function was added for splitting a path into its slash-separated segments.
|
||||
- Added possibility to disable HTTP keep-alive to support backends not properly handling it.
|
||||
- When issuing a challenge, Anubis stores information about that challenge into the store. That stored information is later used to validate challenge responses. This works around nondeterminism in bot rules. ([#917](https://github.com/TecharoHQ/anubis/issues/917))
|
||||
- One of the biggest sources of lag in Firefox has been eliminated: the use of WebCrypto. Now whenever Anubis detects the client is using Firefox (or Pale Moon), it will swap over to a pure-JS implementation of SHA-256 for speed.
|
||||
- Proof of work solving has had a complete overhaul and rethink based on feedback from browser engine developers, frontend experts, and overall performance profiling.
|
||||
- Optimize the performance of the pure-JS Anubis solver.
|
||||
- Web Workers are stored as dedicated JavaScript files in `static/js/workers/*.mjs`.
|
||||
- Pave the way for non-SHA256 solver methods and eventually one that uses WebAssembly (or WebAssembly code compiled to JS for those that disable WebAssembly).
|
||||
- Legacy JavaScript code has been eliminated.
|
||||
- When parsing [Open Graph tags](./admin/configuration/open-graph.mdx), add any URLs found in the responses to a temporary "allow cache" so that social preview images work.
|
||||
- The hard dependency on WebCrypto has been removed, allowing a proof of work challenge to work over plain (unencrypted) HTTP.
|
||||
- The Anubis version number is put in the footer of every page.
|
||||
- Add a default block rule for Huawei Cloud.
|
||||
- Add a default block rule for Alibaba Cloud.
|
||||
- Added support to use Traefik forwardAuth middleware.
|
||||
- Add X-Request-URI support so that Subrequest Authentication has path support.
|
||||
- Added glob matching for `REDIRECT_DOMAINS`. You can pass `*.bugs.techaro.lol` to allow redirecting to anything ending with `.bugs.techaro.lol`. There is a limit of 4 wildcards.
|
||||
|
||||
### Fixes
|
||||
|
||||
#### Odd numbers of CPU cores are properly supported
|
||||
|
||||
Some phones have an odd number of CPU cores. This caused [interesting issues](https://anubis.techaro.lol/blog/2025/cpu-core-odd). This was fixed by [using `Math.trunc` to convert the number of CPU cores back into an integer](https://github.com/TecharoHQ/anubis/issues/1043).
|
||||
|
||||
#### Smaller fixes
|
||||
|
||||
- A standard library HTTP server log message about HTTP pipelining not working has been filtered out of Anubis' logs. There is no action that can be taken about it.
|
||||
- Add a "proof of React" challenge to prove that the client is able to run a simple JSX app.
|
||||
- Added possibility to disable HTTP keep-alive to support backends not properly
|
||||
handling it.
|
||||
- Add a server-side check for the meta-refresh challenge that makes sure clients have waited for at least 95% of the time that they should.
|
||||
- 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)).
|
||||
- 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)).
|
||||
- The [`segments`](./admin/configuration/expressions.mdx#segments) function was added for splitting a path into its slash-separated segments.
|
||||
- When issuing a challenge, Anubis stores information about that challenge into the store. That stored information is later used to validate challenge responses. This works around nondeterminism in bot rules. ([#917](https://github.com/TecharoHQ/anubis/issues/917))
|
||||
- When parsing [Open Graph tags](./admin/configuration/open-graph.mdx), add any URLs found in the responses to a temporary "allow cache" so that social preview images work.
|
||||
- Proof of work solving has had a complete overhaul and rethink based on feedback from browser engine developers, frontend experts, and overall performance profiling.
|
||||
- One of the biggest sources of lag in Firefox has been eliminated: the use of WebCrypto. Now whenever Anubis detects the client is using Firefox (or Pale Moon), it will swap over to a pure-JS implementation of SHA-256 for speed.
|
||||
- Optimize the performance of the pure-JS Anubis solver.
|
||||
- Web Workers are stored as dedicated JavaScript files in `static/js/workers/*.mjs`.
|
||||
- Pave the way for non-SHA256 solver methods and eventually one that uses WebAssembly (or WebAssembly code compiled to JS for those that disable WebAssembly).
|
||||
- Legacy JavaScript code has been eliminated.
|
||||
- Add option for replacing the default explanation text with a custom one ([#747](https://github.com/TecharoHQ/anubis/pull/747))
|
||||
- The contact email in the LibreJS header has been changed.
|
||||
- The hard dependency on WebCrypto has been removed, allowing a proof of work challenge to work over plain (unencrypted) HTTP.
|
||||
- Firefox for Android support has been fixed by embedding the challenge ID into the pass-challenge route. This also fixes some inconsistent issues with other mobile browsers.
|
||||
- The Anubis version number is put in the footer of every page.
|
||||
- Prevent the proof of work nonce from being a decimal value by using Math.trunc to coerce it back to an integer if it happens ([#1043](https://github.com/TecharoHQ/anubis/issues/1043)).
|
||||
- The legacy JSON based policy file example has been removed and all documentation for how to write a policy file in JSON has been deleted. JSON based policy files will still work, but YAML is the superior option for Anubis configuration.
|
||||
- A standard library HTTP server log message about HTTP pipelining not working has been filtered out of Anubis' logs. There is no action that can be taken about it.
|
||||
- The default `favicon` pattern in `data/common/keep-internet-working.yaml` has been updated to permit requests for png/gif/jpg/svg files as well as ico.
|
||||
- The `--cookie-prefix` flag has been fixed so that it is fully respected.
|
||||
- The default patterns in `data/common/keep-internet-working.yaml` have been updated to appropriately escape the '.' character in the regular expression patterns.
|
||||
- Add optional restrictions for JWT based on the value of a header ([#697](https://github.com/TecharoHQ/anubis/pull/697))
|
||||
- The word "hack" has been removed from the translation strings for Anubis due to incidents involving people misunderstanding that word and sending particularly horrible things to the project lead over email.
|
||||
- Bump AI-robots.txt to version 1.39
|
||||
- Inject adversarial input to break AI coding assistants.
|
||||
- Add a default block rule for Huawei Cloud.
|
||||
- Add a default block rule for Alibaba Cloud.
|
||||
- Add X-Request-URI support so that Subrequest Authentication has path support.
|
||||
- Add better logging when using Subrequest Authentication.
|
||||
- Two of Slackware's community git repository servers are now poxied by Anubis.
|
||||
- Added support to use Traefik forwardAuth middleware.
|
||||
- Inject adversarial input to break AI coding assistants.
|
||||
|
||||
### Security-relevant changes
|
||||
|
||||
- Add a server-side check for the meta-refresh challenge that makes sure clients have waited for at least 95% of the time that they should.
|
||||
|
||||
#### 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 stored in the database.
|
||||
@@ -93,11 +71,15 @@ Thanks to [@taviso](https://github.com/taviso) for reporting this issue.
|
||||
### Breaking changes
|
||||
|
||||
- The "slow" frontend solver has been removed in order to reduce maintenance burden. Any existing uses of it will still work, but issue a warning upon startup asking administrators to upgrade to the "fast" frontend solver.
|
||||
- The legacy JSON based policy file example has been removed and all documentation for how to write a policy file in JSON has been deleted. JSON based policy files will still work, but YAML is the superior option for Anubis configuration.
|
||||
|
||||
### New Locales
|
||||
|
||||
- Lithuanian [#972](https://github.com/TecharoHQ/anubis/pull/972)
|
||||
- [Lithuanian](https://github.com/TecharoHQ/anubis/pull/972)
|
||||
|
||||
### Added
|
||||
|
||||
Anubis now supports these new languages:
|
||||
|
||||
- Vietnamese [#926](https://github.com/TecharoHQ/anubis/pull/926)
|
||||
|
||||
## v1.21.3: Minfilia Warde - Echo 3
|
||||
|
||||
@@ -197,96 +197,6 @@ $ du -hs *
|
||||
8.0K reject.webp
|
||||
```
|
||||
|
||||
## Custom HTML templates
|
||||
|
||||
If you need to completely control the HTML layout of all Anubis pages, you can customize the entire page with `USE_TEMPLATES=true`. This uses Go's standard library [html/template](https://pkg.go.dev/html/template) package to template HTML responses. In order to use this, you must define the following templates:
|
||||
|
||||
| Template path | Usage |
|
||||
| :----------------------------------------- | :---------------------------------------------- |
|
||||
| `$OVERLAY_FOLDER/templates/challenge.tmpl` | Challenge pages |
|
||||
| `$OVERLAY_FOLDER/templates/error.tmpl` | Error pages |
|
||||
| `$OVERLAY_FOLDER/templates/impressum.tmpl` | [Impressum](./configuration/impressum.mdx) page |
|
||||
|
||||
Here are minimal (but working) examples for each template:
|
||||
|
||||
<details>
|
||||
<summary>`challenge.tmpl`</summary>
|
||||
|
||||
:::note
|
||||
|
||||
You **MUST** include the `{{.Head}}` segment in a `<head>` tag. It contains important information for challenges to execute. If you don't include this, no clients will be able to pass challenges.
|
||||
|
||||
:::
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ .Lang }}">
|
||||
<head>
|
||||
{{ .Head }}
|
||||
</head>
|
||||
<body>
|
||||
{{ .Body }}
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>`error.tmpl`</summary>
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ .Lang }}">
|
||||
<body>
|
||||
{{ .Body }}
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>`impressum.tmpl`</summary>
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ .Lang }}">
|
||||
<body>
|
||||
{{ .Body }}
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Template functions
|
||||
|
||||
In order to make life easier, the following template functions are defined:
|
||||
|
||||
#### `Asset`
|
||||
|
||||
Constructs the path for a static asset in the [overlay folder](#custom-images-and-css)'s `static` directory.
|
||||
|
||||
```go
|
||||
func Asset(string) string
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```html
|
||||
<link rel="stylesheet" href="{{ Asset "css/example.css" }}" />
|
||||
```
|
||||
|
||||
Generates:
|
||||
|
||||
```html
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/.within.website/x/cmd/anubis/static/css/example.css"
|
||||
/>
|
||||
```
|
||||
|
||||
## Customizing messages
|
||||
|
||||
You can customize messages using the following environment variables:
|
||||
|
||||
@@ -196,83 +196,6 @@ store:
|
||||
path: /data/anubis.bdb
|
||||
```
|
||||
|
||||
### `s3api`
|
||||
|
||||
A network-backed storage layer backed by [object storage](https://en.wikipedia.org/wiki/Object_storage), specifically using the [S3 API](https://docs.aws.amazon.com/AmazonS3/latest/API/Type_API_Reference.html). This can be backed by any S3-compatible object storage service such as:
|
||||
|
||||
- [AWS S3](https://aws.amazon.com/s3/)
|
||||
- [Cloudflare R2](https://www.cloudflare.com/developer-platform/products/r2/)
|
||||
- [Hetzner Object Storage](https://www.hetzner.com/storage/object-storage/)
|
||||
- [Minio](https://www.min.io/)
|
||||
- [Tigris](https://www.tigrisdata.com/)
|
||||
|
||||
If you are using a cloud platform, they likely provide an S3 compatible object storage service. If not, you may want to choose [one of the fastest options](https://www.tigrisdata.com/blog/benchmark-small-objects/).
|
||||
|
||||
| Should I use this backend? | Yes/no |
|
||||
| :------------------------------------------------------------ | :----- |
|
||||
| Are you running only one instance of Anubis for this service? | 🚫 No |
|
||||
| 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 |
|
||||
|
||||
:::note
|
||||
|
||||
Using this backend will cause a lot of S3 operations, at least one for creating challenges, one for invalidating challenges, one for updating challenges to prevent double-spends, and one for removing challenges.
|
||||
|
||||
:::
|
||||
|
||||
#### Configuration
|
||||
|
||||
The `s3api` backend takes the following configuration options:
|
||||
|
||||
| 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
|
||||
|
||||
You should probably enable a lifecycle expiration rule for buckets containing Anubis data. Here is an example policy:
|
||||
|
||||
```json
|
||||
{
|
||||
"Rules": [
|
||||
{
|
||||
"Status": "Enabled",
|
||||
"Expiration": {
|
||||
"Days": 7
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Adjust this as facts and circumstances demand, but 7 days should be enough for anyone.
|
||||
|
||||
:::
|
||||
|
||||
Example:
|
||||
|
||||
Assuming your environment looks like this:
|
||||
|
||||
```sh
|
||||
# All of the following are fake credentials that look like real ones.
|
||||
AWS_ACCESS_KEY_ID=accordingToAllKnownRulesOfAviation
|
||||
AWS_SECRET_ACCESS_KEY=thereIsNoWayABeeShouldBeAbleToFly
|
||||
AWS_REGION=yow
|
||||
AWS_ENDPOINT_URL_S3=https://yow.s3.probably-not-malware.lol
|
||||
```
|
||||
|
||||
Then your configuration would look like this:
|
||||
|
||||
```yaml
|
||||
store:
|
||||
backend: s3api
|
||||
parameters:
|
||||
bucketName: techaro-prod-anubis
|
||||
pathStyle: false
|
||||
```
|
||||
|
||||
### `valkey`
|
||||
|
||||
[Valkey](https://valkey.io/) is an in-memory key/value store that clients access over the network. This allows multiple instances of Anubis to share information and does not require each instance of Anubis to have persistent filesystem storage.
|
||||
|
||||
18
go.mod
18
go.mod
@@ -5,9 +5,6 @@ go 1.24.2
|
||||
require (
|
||||
github.com/TecharoHQ/thoth-proto v0.4.0
|
||||
github.com/a-h/templ v0.3.924
|
||||
github.com/aws/aws-sdk-go-v2 v1.38.3
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.6
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3
|
||||
github.com/cespare/xxhash/v2 v2.3.0
|
||||
github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456
|
||||
github.com/gaissmai/bart v0.23.0
|
||||
@@ -52,21 +49,6 @@ require (
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 // indirect
|
||||
github.com/aws/smithy-go v1.23.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect
|
||||
github.com/cavaliergopher/cpio v1.0.1 // indirect
|
||||
|
||||
36
go.sum
36
go.sum
@@ -51,42 +51,6 @@ github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYW
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk=
|
||||
github.com/aws/aws-sdk-go-v2 v1.38.3/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.6 h1:a1t8fXY4GT4xjyJExz4knbuoxSCacB5hT/WgtfPyLjo=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.6/go.mod h1:5ByscNi7R+ztvOGzeUaIu49vkMk2soq5NaH5PYe33MQ=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.10 h1:xdJnXCouCx8Y0NncgoptztUocIYLKeQxrCgN6x9sdhg=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.10/go.mod h1:7tQk08ntj914F/5i9jC4+2HQTAuJirq7m1vZVIhEkWs=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 h1:wbjnrrMnKew78/juW7I2BtKQwa1qlf6EjQgS69uYY14=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6/go.mod h1:AtiqqNrDioJXuUgz3+3T0mBWN7Hro2n9wll2zRUc0ww=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 h1:uF68eJA6+S9iVr9WgX1NaRGyQ/6MdIyc4JNUo6TN1FA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6/go.mod h1:qlPeVZCGPiobx8wb1ft0GHT5l+dc6ldnwInDFaMvC7Y=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 h1:pa1DEC6JoI0zduhZePp3zmhWvk/xxm4NB8Hy/Tlsgos=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6/go.mod h1:gxEjPebnhWGJoaDdtDkA0JX46VRg1wcTHYe63OfX5pE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 h1:R0tNFJqfjHL3900cqhXuwQ+1K4G0xc9Yf8EDbFXCKEw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6/go.mod h1:y/7sDdu+aJvPtGXr4xYosdpq9a6T9Z0jkXfugmti0rI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 h1:hncKj/4gR+TPauZgTAsxOxNcvBayhUlYZ6LO/BYiQ30=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6/go.mod h1:OiIh45tp6HdJDDJGnja0mw8ihQGz3VGrUflLqSL0SmM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 h1:LHS1YAIJXJ4K9zS+1d/xa9JAA9sL2QyXIQCQFQW/X08=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6/go.mod h1:c9PCiTEuh0wQID5/KqA32J+HAgZxN9tOGXKCiYJjTZI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 h1:nEXUSAwyUfLTgnc9cxlDWy637qsq4UWwp3sNAfl0Z3Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6/go.mod h1:HGzIULx4Ge3Do2V0FaiYKcyKzOqwrhUZgCI77NisswQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 h1:ETkfWcXP2KNPLecaDa++5bsQhCRa5M5sLUJa5DWYIIg=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3/go.mod h1:+/3ZTqoYb3Ur7DObD00tarKMLMuKg8iqz5CHEanqTnw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 h1:8OLZnVJPvjnrxEwHFg9hVUof/P4sibH+Ea4KKuqAGSg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1/go.mod h1:27M3BpVi0C02UiQh1w9nsBEit6pLhlaH3NHna6WUbDE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 h1:gKWSTnqudpo8dAxqBqZnDoDWCiEh/40FziUjr/mo6uA=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2/go.mod h1:x7+rkNmRoEN1U13A6JE2fXne9EWyJy54o3n6d4mGaXQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 h1:YZPjhyaGzhDQEvsffDEcpycq49nl7fiGcfJTIo8BszI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2/go.mod h1:2dIN8qhQfv37BdUYGgEC8Q3tteM3zFxTI1MLO2O3J3c=
|
||||
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
|
||||
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb h1:m935MPodAbYS46DG4pJSv7WO+VECIWUQ7OJYSoTrMh4=
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package glob
|
||||
|
||||
import "strings"
|
||||
|
||||
const GLOB = "*"
|
||||
|
||||
const maxGlobParts = 5
|
||||
|
||||
// Glob will test a string pattern, potentially containing globs, against a
|
||||
// subject string. The result is a simple true/false, determining whether or
|
||||
// not the glob pattern matched the subject text.
|
||||
func Glob(pattern, subj string) bool {
|
||||
// Empty pattern can only match empty subject
|
||||
if pattern == "" {
|
||||
return subj == pattern
|
||||
}
|
||||
|
||||
// If the pattern _is_ a glob, it matches everything
|
||||
if pattern == GLOB {
|
||||
return true
|
||||
}
|
||||
|
||||
parts := strings.Split(pattern, GLOB)
|
||||
|
||||
if len(parts) > maxGlobParts {
|
||||
return false // Pattern is too complex, reject it.
|
||||
}
|
||||
|
||||
if len(parts) == 1 {
|
||||
// No globs in pattern, so test for equality
|
||||
return subj == pattern
|
||||
}
|
||||
|
||||
leadingGlob := strings.HasPrefix(pattern, GLOB)
|
||||
trailingGlob := strings.HasSuffix(pattern, GLOB)
|
||||
end := len(parts) - 1
|
||||
|
||||
// Go over the leading parts and ensure they match.
|
||||
for i := 0; i < end; i++ {
|
||||
idx := strings.Index(subj, parts[i])
|
||||
|
||||
switch i {
|
||||
case 0:
|
||||
// Check the first section. Requires special handling.
|
||||
if !leadingGlob && idx != 0 {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
// Check that the middle parts match.
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Trim evaluated text from subj as we loop over the pattern.
|
||||
subj = subj[idx+len(parts[i]):]
|
||||
}
|
||||
|
||||
// Reached the last section. Requires special handling.
|
||||
return trailingGlob || strings.HasSuffix(subj, parts[end])
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
package glob
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGlob_EqualityAndEmpty(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
pattern string
|
||||
subj string
|
||||
want bool
|
||||
}{
|
||||
{"exact match", "hello", "hello", true},
|
||||
{"exact mismatch", "hello", "hell", false},
|
||||
{"empty pattern and subject", "", "", true},
|
||||
{"empty pattern with non-empty subject", "", "x", false},
|
||||
{"pattern star matches empty", "*", "", true},
|
||||
{"pattern star matches anything", "*", "anything at all", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := Glob(tc.pattern, tc.subj); got != tc.want {
|
||||
t.Fatalf("Glob(%q,%q) = %v, want %v", tc.pattern, tc.subj, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlob_LeadingAndTrailing(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
pattern string
|
||||
subj string
|
||||
want bool
|
||||
}{
|
||||
{"prefix match - minimal", "foo*", "foo", true},
|
||||
{"prefix match - extended", "foo*", "foobar", true},
|
||||
{"prefix mismatch - not at start", "foo*", "xfoo", false},
|
||||
{"suffix match - minimal", "*foo", "foo", true},
|
||||
{"suffix match - extended", "*foo", "xfoo", true},
|
||||
{"suffix mismatch - not at end", "*foo", "foox", false},
|
||||
{"contains match", "*foo*", "barfoobaz", true},
|
||||
{"contains mismatch - missing needle", "*foo*", "f", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := Glob(tc.pattern, tc.subj); got != tc.want {
|
||||
t.Fatalf("Glob(%q,%q) = %v, want %v", tc.pattern, tc.subj, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlob_MiddleAndOrder(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
pattern string
|
||||
subj string
|
||||
want bool
|
||||
}{
|
||||
{"middle wildcard basic", "f*o", "fo", true},
|
||||
{"middle wildcard gap", "f*o", "fZZZo", true},
|
||||
{"middle wildcard requires start f", "f*o", "xfyo", false},
|
||||
{"order enforced across parts", "a*b*c*d", "axxbxxcxxd", true},
|
||||
{"order mismatch fails", "a*b*c*d", "abdc", false},
|
||||
{"must end with last part when no trailing *", "*foo*bar", "zzfooqqbar", true},
|
||||
{"failing when trailing chars remain", "*foo*bar", "zzfooqqbarzz", false},
|
||||
{"first part must start when no leading *", "foo*bar", "zzfooqqbar", false},
|
||||
{"works with overlapping content", "ab*ba", "ababa", true},
|
||||
{"needle not found fails", "foo*bar", "foobaz", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := Glob(tc.pattern, tc.subj); got != tc.want {
|
||||
t.Fatalf("Glob(%q,%q) = %v, want %v", tc.pattern, tc.subj, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlob_ConsecutiveStarsAndEmptyParts(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
pattern string
|
||||
subj string
|
||||
want bool
|
||||
}{
|
||||
{"double star matches anything", "**", "", true},
|
||||
{"double star matches anything non-empty", "**", "abc", true},
|
||||
{"consecutive stars behave like single", "a**b", "ab", true},
|
||||
{"consecutive stars with gaps", "a**b", "axxxb", true},
|
||||
{"consecutive stars + trailing star", "a**b*", "axxbzzz", true},
|
||||
{"consecutive stars still enforce anchors", "a**b", "xaBy", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := Glob(tc.pattern, tc.subj); got != tc.want {
|
||||
t.Fatalf("Glob(%q,%q) = %v, want %v", tc.pattern, tc.subj, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlob_MaxPartsLimit(t *testing.T) {
|
||||
// Allowed: up to 4 '*' (5 parts)
|
||||
allowed := []struct {
|
||||
pattern string
|
||||
subj string
|
||||
want bool
|
||||
}{
|
||||
{"a*b*c*d*e", "axxbxxcxxdxxe", true}, // 4 stars -> 5 parts
|
||||
{"*a*b*c*d", "zzzaaaabbbcccddd", true},
|
||||
{"a*b*c*d*e", "abcde", true},
|
||||
{"a*b*c*d*e", "abxdxe", false}, // missing 'c' should fail
|
||||
}
|
||||
for _, tc := range allowed {
|
||||
if got := Glob(tc.pattern, tc.subj); got != tc.want {
|
||||
t.Fatalf("allowed pattern Glob(%q,%q) = %v, want %v", tc.pattern, tc.subj, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Disallowed: 5 '*' (6 parts) -> always false by complexity check
|
||||
disallowed := []struct {
|
||||
pattern string
|
||||
subj string
|
||||
}{
|
||||
{"a*b*c*d*e*f", "aXXbYYcZZdQQeRRf"},
|
||||
{"*a*b*c*d*e*", "abcdef"},
|
||||
{"******", "anything"}, // 6 stars -> 7 parts
|
||||
}
|
||||
for _, tc := range disallowed {
|
||||
if got := Glob(tc.pattern, tc.subj); got {
|
||||
t.Fatalf("disallowed pattern should fail Glob(%q,%q) = %v, want false", tc.pattern, tc.subj, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlob_CaseSensitivity(t *testing.T) {
|
||||
cases := []struct {
|
||||
pattern string
|
||||
subj string
|
||||
want bool
|
||||
}{
|
||||
{"FOO*", "foo", false},
|
||||
{"*Bar", "bar", false},
|
||||
{"Foo*Bar", "FooZZZBar", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := Glob(tc.pattern, tc.subj); got != tc.want {
|
||||
t.Fatalf("Glob(%q,%q) = %v, want %v", tc.pattern, tc.subj, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlob_EmptySubjectInteractions(t *testing.T) {
|
||||
cases := []struct {
|
||||
pattern string
|
||||
subj string
|
||||
want bool
|
||||
}{
|
||||
{"*a", "", false},
|
||||
{"a*", "", false},
|
||||
{"**", "", true},
|
||||
{"*", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := Glob(tc.pattern, tc.subj); got != tc.want {
|
||||
t.Fatalf("Glob(%q,%q) = %v, want %v", tc.pattern, tc.subj, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGlob(b *testing.B) {
|
||||
patterns := []string{
|
||||
"*", "*foo*", "foo*bar", "a*b*c*d*e", "a**b*", "*needle*end",
|
||||
}
|
||||
subjects := []string{
|
||||
"", "foo", "barfoo", "foobarbaz", "axxbxxcxxdxxe", "zzfooqqbarzz",
|
||||
"lorem ipsum dolor sit amet, consectetur adipiscing elit",
|
||||
}
|
||||
for _, p := range patterns {
|
||||
for _, s := range subjects {
|
||||
b.Run(p+"::"+s, func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = Glob(p, s)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -434,7 +435,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
s.respondWithError(w, r, localizer.T("redirect_not_parseable"))
|
||||
return
|
||||
}
|
||||
if (len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !matchRedirectDomain(s.opts.RedirectDomains, urlParsed.Host)) || urlParsed.Host != r.URL.Host {
|
||||
if (len(urlParsed.Host) > 0 && len(s.opts.RedirectDomains) != 0 && !slices.Contains(s.opts.RedirectDomains, urlParsed.Host)) || urlParsed.Host != r.URL.Host {
|
||||
lg.Debug("domain not allowed", "domain", urlParsed.Host)
|
||||
s.respondWithError(w, r, localizer.T("redirect_domain_not_allowed"))
|
||||
return
|
||||
|
||||
28
lib/http.go
28
lib/http.go
@@ -7,12 +7,12 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TecharoHQ/anubis"
|
||||
"github.com/TecharoHQ/anubis/internal"
|
||||
"github.com/TecharoHQ/anubis/internal/glob"
|
||||
"github.com/TecharoHQ/anubis/lib/challenge"
|
||||
"github.com/TecharoHQ/anubis/lib/localization"
|
||||
"github.com/TecharoHQ/anubis/lib/policy"
|
||||
@@ -24,26 +24,6 @@ import (
|
||||
|
||||
var domainMatchRegexp = regexp.MustCompile(`^((xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$`)
|
||||
|
||||
// matchRedirectDomain returns true if host matches any of the allowed redirect
|
||||
// domain patterns. Patterns may contain '*' which are matched using the
|
||||
// internal glob matcher. Matching is case-insensitive on hostnames.
|
||||
func matchRedirectDomain(allowed []string, host string) bool {
|
||||
h := strings.ToLower(strings.TrimSpace(host))
|
||||
for _, pat := range allowed {
|
||||
p := strings.ToLower(strings.TrimSpace(pat))
|
||||
if strings.Contains(p, glob.GLOB) {
|
||||
if glob.Glob(p, h) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if p == h {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type CookieOpts struct {
|
||||
Value string
|
||||
Host string
|
||||
@@ -237,8 +217,8 @@ func (s *Server) constructRedirectURL(r *http.Request) (string, error) {
|
||||
if proto == "" || host == "" || uri == "" {
|
||||
return "", errors.New(localizer.T("missing_required_forwarded_headers"))
|
||||
}
|
||||
// Check if host is allowed in RedirectDomains (supports '*' via glob)
|
||||
if len(s.opts.RedirectDomains) > 0 && !matchRedirectDomain(s.opts.RedirectDomains, host) {
|
||||
// Check if host is allowed in RedirectDomains
|
||||
if len(s.opts.RedirectDomains) > 0 && !slices.Contains(s.opts.RedirectDomains, host) {
|
||||
lg := internal.GetRequestLogger(s.logger, r)
|
||||
lg.Debug("domain not allowed", "domain", host)
|
||||
return "", errors.New(localizer.T("redirect_domain_not_allowed"))
|
||||
@@ -310,7 +290,7 @@ func (s *Server) ServeHTTPNext(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
hostNotAllowed := len(urlParsed.Host) > 0 &&
|
||||
len(s.opts.RedirectDomains) != 0 &&
|
||||
!matchRedirectDomain(s.opts.RedirectDomains, urlParsed.Host)
|
||||
!slices.Contains(s.opts.RedirectDomains, urlParsed.Host)
|
||||
hostMismatch := r.URL.Host != "" && urlParsed.Host != r.URL.Host
|
||||
|
||||
if hostNotAllowed || hostMismatch {
|
||||
|
||||
@@ -6,6 +6,5 @@ package all
|
||||
import (
|
||||
_ "github.com/TecharoHQ/anubis/lib/store/bbolt"
|
||||
_ "github.com/TecharoHQ/anubis/lib/store/memory"
|
||||
_ "github.com/TecharoHQ/anubis/lib/store/s3api"
|
||||
_ "github.com/TecharoHQ/anubis/lib/store/valkey"
|
||||
)
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/TecharoHQ/anubis/lib/store"
|
||||
awsConfig "github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoRegion = errors.New("s3api.Config: no region env var name defined")
|
||||
ErrNoAccessKeyID = errors.New("s3api.Config: no access key id env var name defined")
|
||||
ErrNoSecretAccessKey = errors.New("s3api.Config: no secret access key env var name defined")
|
||||
ErrNoBucketName = errors.New("s3api.Config: no bucket name env var name defined")
|
||||
)
|
||||
|
||||
func init() {
|
||||
store.Register("s3api", Factory{})
|
||||
}
|
||||
|
||||
// S3API is the subset of the AWS S3 client used by this store. It enables mocking in tests.
|
||||
type S3API interface {
|
||||
PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error)
|
||||
GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error)
|
||||
DeleteObject(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error)
|
||||
HeadObject(ctx context.Context, params *s3.HeadObjectInput, optFns ...func(*s3.Options)) (*s3.HeadObjectOutput, error)
|
||||
}
|
||||
|
||||
// Factory builds an S3-backed store. Tests can inject a Mock via Client.
|
||||
// Factory can optionally carry a preconstructed S3 client (e.g., a mock in tests).
|
||||
type Factory struct {
|
||||
Client S3API
|
||||
}
|
||||
|
||||
func (f Factory) Build(ctx context.Context, data json.RawMessage) (store.Interface, error) {
|
||||
var config Config
|
||||
|
||||
if err := json.Unmarshal([]byte(data), &config); err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", store.ErrBadConfig, err)
|
||||
}
|
||||
|
||||
if err := config.Valid(); err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", store.ErrBadConfig, err)
|
||||
}
|
||||
|
||||
if config.BucketName == "" {
|
||||
return nil, fmt.Errorf("%w: %s", store.ErrBadConfig, ErrNoBucketName)
|
||||
}
|
||||
|
||||
// If a client was injected (e.g., tests), use it directly.
|
||||
if f.Client != nil {
|
||||
return &Store{
|
||||
s3: f.Client,
|
||||
bucket: config.BucketName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
cfg, err := awsConfig.LoadDefaultConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't load AWS config from environment: %w", err)
|
||||
}
|
||||
|
||||
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
|
||||
o.UsePathStyle = config.PathStyle
|
||||
})
|
||||
|
||||
return &Store{
|
||||
s3: client,
|
||||
bucket: config.BucketName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (Factory) Valid(data json.RawMessage) error {
|
||||
var config Config
|
||||
if err := json.Unmarshal([]byte(data), &config); err != nil {
|
||||
return fmt.Errorf("%w: %w", store.ErrBadConfig, err)
|
||||
}
|
||||
|
||||
if err := config.Valid(); err != nil {
|
||||
return fmt.Errorf("%w: %w", store.ErrBadConfig, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
PathStyle bool `json:"pathStyle"`
|
||||
BucketName string `json:"bucketName"`
|
||||
}
|
||||
|
||||
func (c Config) Valid() error {
|
||||
var errs []error
|
||||
|
||||
if c.BucketName == "" {
|
||||
errs = append(errs, ErrNoBucketName)
|
||||
}
|
||||
|
||||
if len(errs) != 0 {
|
||||
return fmt.Errorf("s3api.Config: invalid config: %w", errors.Join(errs...))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TecharoHQ/anubis/lib/store"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
s3 S3API
|
||||
bucket string
|
||||
}
|
||||
|
||||
func (s *Store) Delete(ctx context.Context, key string) error {
|
||||
normKey := strings.ReplaceAll(key, ":", "/")
|
||||
// Emulate not found by probing first.
|
||||
if _, err := s.s3.HeadObject(ctx, &s3.HeadObjectInput{Bucket: &s.bucket, Key: &normKey}); err != nil {
|
||||
return fmt.Errorf("%w: %w", store.ErrNotFound, err)
|
||||
}
|
||||
if _, err := s.s3.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: &s.bucket, Key: &normKey}); err != nil {
|
||||
return fmt.Errorf("can't delete from s3: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Get(ctx context.Context, key string) ([]byte, error) {
|
||||
normKey := strings.ReplaceAll(key, ":", "/")
|
||||
out, err := s.s3.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: &s.bucket,
|
||||
Key: &normKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", store.ErrNotFound, err)
|
||||
}
|
||||
defer out.Body.Close()
|
||||
if msStr, ok := out.Metadata["x-anubis-expiry-ms"]; ok && msStr != "" {
|
||||
if ms, err := strconv.ParseInt(msStr, 10, 64); err == nil {
|
||||
if time.Now().UnixMilli() >= ms {
|
||||
_, _ = s.s3.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: &s.bucket, Key: &normKey})
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
}
|
||||
}
|
||||
b, err := io.ReadAll(out.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't read s3 object: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (s *Store) Set(ctx context.Context, key string, value []byte, expiry time.Duration) error {
|
||||
normKey := strings.ReplaceAll(key, ":", "/")
|
||||
// S3 has no native TTL; we store object with metadata X-Anubis-Expiry as epoch seconds.
|
||||
var meta map[string]string
|
||||
if expiry > 0 {
|
||||
exp := time.Now().Add(expiry).UnixMilli()
|
||||
meta = map[string]string{"x-anubis-expiry-ms": fmt.Sprintf("%d", exp)}
|
||||
}
|
||||
_, err := s.s3.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: &s.bucket,
|
||||
Key: &normKey,
|
||||
Body: bytes.NewReader(value),
|
||||
Metadata: meta,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("can't put s3 object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (Store) IsPersistent() bool { return true }
|
||||
@@ -1,140 +0,0 @@
|
||||
package s3api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TecharoHQ/anubis/lib/store/storetest"
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
)
|
||||
|
||||
// mockS3 is an in-memory mock of the methods we use.
|
||||
type mockS3 struct {
|
||||
mu sync.RWMutex
|
||||
bucket string
|
||||
data map[string][]byte
|
||||
meta map[string]map[string]string
|
||||
}
|
||||
|
||||
func (m *mockS3) PutObject(ctx context.Context, in *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.data == nil {
|
||||
m.data = map[string][]byte{}
|
||||
}
|
||||
if m.meta == nil {
|
||||
m.meta = map[string]map[string]string{}
|
||||
}
|
||||
b, _ := io.ReadAll(in.Body)
|
||||
m.data[aws.ToString(in.Key)] = bytes.Clone(b)
|
||||
if in.Metadata != nil {
|
||||
m.meta[aws.ToString(in.Key)] = map[string]string{}
|
||||
for k, v := range in.Metadata {
|
||||
m.meta[aws.ToString(in.Key)][k] = v
|
||||
}
|
||||
}
|
||||
m.bucket = aws.ToString(in.Bucket)
|
||||
return &s3.PutObjectOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *mockS3) GetObject(ctx context.Context, in *s3.GetObjectInput, _ ...func(*s3.Options)) (*s3.GetObjectOutput, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
b, ok := m.data[aws.ToString(in.Key)]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("not found")
|
||||
}
|
||||
out := &s3.GetObjectOutput{Body: io.NopCloser(bytes.NewReader(b))}
|
||||
if md, ok := m.meta[aws.ToString(in.Key)]; ok {
|
||||
out.Metadata = md
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *mockS3) DeleteObject(ctx context.Context, in *s3.DeleteObjectInput, _ ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.data, aws.ToString(in.Key))
|
||||
delete(m.meta, aws.ToString(in.Key))
|
||||
return &s3.DeleteObjectOutput{}, nil
|
||||
}
|
||||
|
||||
func (m *mockS3) HeadObject(ctx context.Context, in *s3.HeadObjectInput, _ ...func(*s3.Options)) (*s3.HeadObjectOutput, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if _, ok := m.data[aws.ToString(in.Key)]; !ok {
|
||||
return nil, fmt.Errorf("not found")
|
||||
}
|
||||
return &s3.HeadObjectOutput{}, nil
|
||||
}
|
||||
|
||||
func TestImpl(t *testing.T) {
|
||||
mock := &mockS3{}
|
||||
f := Factory{Client: mock}
|
||||
|
||||
data, _ := json.Marshal(Config{
|
||||
BucketName: "bucket",
|
||||
})
|
||||
|
||||
storetest.Common(t, f, json.RawMessage(data))
|
||||
}
|
||||
|
||||
func TestKeyNormalization(t *testing.T) {
|
||||
mock := &mockS3{}
|
||||
f := Factory{Client: mock}
|
||||
|
||||
data, _ := json.Marshal(Config{
|
||||
BucketName: "anubis",
|
||||
})
|
||||
|
||||
s, err := f.Build(t.Context(), json.RawMessage(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
key := "a:b:c"
|
||||
val := []byte("value")
|
||||
if err := s.Set(t.Context(), key, val, 0); err != nil {
|
||||
t.Fatalf("Set failed: %v", err)
|
||||
}
|
||||
// Ensure mock saw normalized key
|
||||
mock.mu.RLock()
|
||||
_, hasRaw := mock.data["a:b:c"]
|
||||
got, hasNorm := mock.data["a/b/c"]
|
||||
mock.mu.RUnlock()
|
||||
if hasRaw {
|
||||
t.Fatalf("mock contains raw key with colon; normalization failed")
|
||||
}
|
||||
if !hasNorm || !bytes.Equal(got, val) {
|
||||
t.Fatalf("normalized key missing or wrong value: got=%q", string(got))
|
||||
}
|
||||
|
||||
// Get using colon key should work
|
||||
out, err := s.Get(t.Context(), key)
|
||||
if err != nil {
|
||||
t.Fatalf("Get failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(out, val) {
|
||||
t.Fatalf("Get returned wrong value: got=%q", string(out))
|
||||
}
|
||||
|
||||
// Delete using colon key should delete normalized object
|
||||
if err := s.Delete(t.Context(), key); err != nil {
|
||||
t.Fatalf("Delete failed: %v", err)
|
||||
}
|
||||
// Give any async cleanup in tests a tick (not needed for mock, but harmless)
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
mock.mu.RLock()
|
||||
_, exists := mock.data["a/b/c"]
|
||||
mock.mu.RUnlock()
|
||||
if exists {
|
||||
t.Fatalf("normalized key still exists after Delete")
|
||||
}
|
||||
}
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@techaro/anubis",
|
||||
"version": "1.22.0",
|
||||
"version": "1.22.0-pre2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@techaro/anubis",
|
||||
"version": "1.22.0",
|
||||
"version": "1.22.0-pre2",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@aws-crypto/sha256-js": "^5.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@techaro/anubis",
|
||||
"version": "1.22.0",
|
||||
"version": "1.22.0-pre2",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
20
test/go.mod
20
test/go.mod
@@ -5,7 +5,7 @@ go 1.24.5
|
||||
replace github.com/TecharoHQ/anubis => ..
|
||||
|
||||
require (
|
||||
github.com/TecharoHQ/anubis v1.22.0
|
||||
github.com/TecharoHQ/anubis v1.21.3
|
||||
github.com/docker/docker v28.3.2+incompatible
|
||||
github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456
|
||||
github.com/google/uuid v1.6.0
|
||||
@@ -18,24 +18,6 @@ require (
|
||||
github.com/TecharoHQ/thoth-proto v0.4.0 // indirect
|
||||
github.com/a-h/templ v0.3.924 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.38.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 // indirect
|
||||
github.com/aws/smithy-go v1.23.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
|
||||
36
test/go.sum
36
test/go.sum
@@ -16,42 +16,6 @@ github.com/a-h/templ v0.3.924 h1:t5gZqTneXqvehpNZsgtnlOscnBboNh9aASBH2MgV/0k=
|
||||
github.com/a-h/templ v0.3.924/go.mod h1:FFAu4dI//ESmEN7PQkJ7E7QfnSEMdcnu7QrAY8Dn334=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
|
||||
github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk=
|
||||
github.com/aws/aws-sdk-go-v2 v1.38.3/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.6 h1:a1t8fXY4GT4xjyJExz4knbuoxSCacB5hT/WgtfPyLjo=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.6/go.mod h1:5ByscNi7R+ztvOGzeUaIu49vkMk2soq5NaH5PYe33MQ=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.10 h1:xdJnXCouCx8Y0NncgoptztUocIYLKeQxrCgN6x9sdhg=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.10/go.mod h1:7tQk08ntj914F/5i9jC4+2HQTAuJirq7m1vZVIhEkWs=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 h1:wbjnrrMnKew78/juW7I2BtKQwa1qlf6EjQgS69uYY14=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6/go.mod h1:AtiqqNrDioJXuUgz3+3T0mBWN7Hro2n9wll2zRUc0ww=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 h1:uF68eJA6+S9iVr9WgX1NaRGyQ/6MdIyc4JNUo6TN1FA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6/go.mod h1:qlPeVZCGPiobx8wb1ft0GHT5l+dc6ldnwInDFaMvC7Y=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 h1:pa1DEC6JoI0zduhZePp3zmhWvk/xxm4NB8Hy/Tlsgos=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6/go.mod h1:gxEjPebnhWGJoaDdtDkA0JX46VRg1wcTHYe63OfX5pE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 h1:R0tNFJqfjHL3900cqhXuwQ+1K4G0xc9Yf8EDbFXCKEw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6/go.mod h1:y/7sDdu+aJvPtGXr4xYosdpq9a6T9Z0jkXfugmti0rI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 h1:hncKj/4gR+TPauZgTAsxOxNcvBayhUlYZ6LO/BYiQ30=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6/go.mod h1:OiIh45tp6HdJDDJGnja0mw8ihQGz3VGrUflLqSL0SmM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 h1:LHS1YAIJXJ4K9zS+1d/xa9JAA9sL2QyXIQCQFQW/X08=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6/go.mod h1:c9PCiTEuh0wQID5/KqA32J+HAgZxN9tOGXKCiYJjTZI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 h1:nEXUSAwyUfLTgnc9cxlDWy637qsq4UWwp3sNAfl0Z3Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6/go.mod h1:HGzIULx4Ge3Do2V0FaiYKcyKzOqwrhUZgCI77NisswQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 h1:ETkfWcXP2KNPLecaDa++5bsQhCRa5M5sLUJa5DWYIIg=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3/go.mod h1:+/3ZTqoYb3Ur7DObD00tarKMLMuKg8iqz5CHEanqTnw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 h1:8OLZnVJPvjnrxEwHFg9hVUof/P4sibH+Ea4KKuqAGSg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1/go.mod h1:27M3BpVi0C02UiQh1w9nsBEit6pLhlaH3NHna6WUbDE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 h1:gKWSTnqudpo8dAxqBqZnDoDWCiEh/40FziUjr/mo6uA=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2/go.mod h1:x7+rkNmRoEN1U13A6JE2fXne9EWyJy54o3n6d4mGaXQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 h1:YZPjhyaGzhDQEvsffDEcpycq49nl7fiGcfJTIo8BszI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2/go.mod h1:2dIN8qhQfv37BdUYGgEC8Q3tteM3zFxTI1MLO2O3J3c=
|
||||
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
|
||||
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
|
||||
Reference in New Issue
Block a user