Repository: TecharoHQ/anubis

Issue:
Allow importing the entire default botPolicies.yaml?
Hi, thanks for making Anubis!

We just deployed it and need to add custom allow rules. It seems the suggested way to do this is copying https://github.com/TecharoHQ/anubis/blob/main/data/botPolicies.yaml and adding the custom rules at the end. This has the disadvantage that when new imports in the "Pathological bots to deny" or "Search engines to allow" blocks get added upstream, they won't be in the copied config.

How about making it possible to import the entire botPolicies.yaml?

(As a workaround I'll try to only override `keep-internet-working.yaml` - EDIT: this doesn't work, as I understand it is embedded in the binary, I'll just override botPolicies.yaml for now.)

Discussion:
@osmith42: https://github.com/TecharoHQ/anubis/pull/402 solves this. Thank you very much!

Solution patch:
diff --git a/.github/workflows/package-builds-stable.yml b/.github/workflows/package-builds-stable.yml
index b8cc847..3e88141 100644
--- a/.github/workflows/package-builds-stable.yml
+++ b/.github/workflows/package-builds-stable.yml
@@ -64,7 +64,7 @@ jobs:
 
     - name: Build Packages
       run: |
-        wget https://github.com/TecharoHQ/yeet/releases/download/v0.2.1/yeet_0.2.1_amd64.deb -O var/yeet.deb
+        wget https://github.com/TecharoHQ/yeet/releases/download/v0.2.2/yeet_0.2.2_amd64.deb -O var/yeet.deb
         sudo apt -y install -f ./var/yeet.deb
         rm ./var/yeet.deb
         yeet
diff --git a/.github/workflows/package-builds-unstable.yml b/.github/workflows/package-builds-unstable.yml
index 3f4dbb3..f93fbe9 100644
--- a/.github/workflows/package-builds-unstable.yml
+++ b/.github/workflows/package-builds-unstable.yml
@@ -66,7 +66,7 @@ jobs:
 
     - name: Build Packages
       run: |
-        wget https://github.com/TecharoHQ/yeet/releases/download/v0.2.1/yeet_0.2.1_amd64.deb -O var/yeet.deb
+        wget https://github.com/TecharoHQ/yeet/releases/download/v0.2.2/yeet_0.2.2_amd64.deb -O var/yeet.deb
         sudo apt -y install -f ./var/yeet.deb
         rm ./var/yeet.deb
         yeet
diff --git a/VERSION b/VERSION
index 092afa1..511a76e 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.17.0
+1.17.1
diff --git a/anubis.go b/anubis.go
index d626ebc..3d85d01 100644
--- a/anubis.go
+++ b/anubis.go
@@ -1,6 +1,8 @@
 // Package anubis contains the version number of Anubis.
 package anubis
 
+import "time"
+
 // Version is the current version of Anubis.
 //
 // This variable is set at build time using the -X linker flag. If not set,
@@ -11,6 +13,9 @@ var Version = "devel"
 // access.
 const CookieName = "within.website-x-cmd-anubis-auth"
 
+// CookieDefaultExpirationTime is the amount of time before the cookie/JWT expires.
+const CookieDefaultExpirationTime = 7 * 24 * time.Hour
+
 // BasePrefix is a global prefix for all Anubis endpoints. Can be emptied to remove the prefix entirely.
 var BasePrefix = ""
 
diff --git a/cmd/anubis/main.go b/cmd/anubis/main.go
index 5ae7eff..1bef63e 100644
--- a/cmd/anubis/main.go
+++ b/cmd/anubis/main.go
@@ -43,6 +43,7 @@ var (
 	bindNetwork              = flag.String("bind-network", "tcp", "network family to bind HTTP to, e.g. unix, tcp")
 	challengeDifficulty      = flag.Int("difficulty", anubis.DefaultDifficulty, "difficulty of the challenge")
 	cookieDomain             = flag.String("cookie-domain", "", "if set, the top-level domain that the Anubis cookie will be valid for")
+	cookieExpiration         = flag.Duration("cookie-expiration-time", anubis.CookieDefaultExpirationTime, "The amount of time the authorization cookie is valid for")
 	cookiePartitioned        = flag.Bool("cookie-partitioned", false, "if true, sets the partitioned flag on Anubis cookies, enabling CHIPS support")
 	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")
@@ -279,6 +280,7 @@ func main() {
 		ServeRobotsTXT:       *robotsTxt,
 		PrivateKey:           priv,
 		CookieDomain:         *cookieDomain,
+		CookieExpiration:     *cookieExpiration,
 		CookiePartitioned:    *cookiePartitioned,
 		OGPassthrough:        *ogPassthrough,
 		OGTimeToLive:         *ogTimeToLive,
@@ -322,6 +324,7 @@ func main() {
 		"og-passthrough", *ogPassthrough,
 		"og-expiry-time", *ogTimeToLive,
 		"base-prefix", *basePrefix,
+		"cookie-expiration-time", *cookieExpiration,
 	)
 
 	go func() {
diff --git a/data/botPolicies.json b/data/botPolicies.json
index 160bbf0..7af1d90 100644
--- a/data/botPolicies.json
+++ b/data/botPolicies.json
@@ -1,40 +1,13 @@
 {
   "bots": [
     {
-      "import": "(data)/bots/ai-robots-txt.yaml"
-    },
-    {
-      "import": "(data)/bots/cloudflare-workers.yaml"
-    },
-    {
-      "import": "(data)/bots/headless-browsers.yaml"
-    },
-    {
-      "import": "(data)/bots/us-ai-scraper.yaml"
-    },
-    {
-      "import": "(data)/crawlers/googlebot.yaml"
-    },
-    {
-      "import": "(data)/crawlers/bingbot.yaml"
-    },
-    {
-      "import": "(data)/crawlers/duckduckbot.yaml"
-    },
-    {
-      "import": "(data)/crawlers/qwantbot.yaml"
+      "import": "(data)/bots/_deny-pathological.yaml"
     },
     {
-      "import": "(data)/crawlers/internet-archive.yaml"
-    },
-    {
-      "import": "(data)/crawlers/kagibot.yaml"
-    },
-    {
-      "import": "(data)/crawlers/marginalia.yaml"
+      "import": "(data)/bots/ai-robots-txt.yaml"
     },
     {
-      "import": "(data)/crawlers/mojeekbot.yaml"
+      "import": "(data)/crawlers/_allow-good.yaml"
     },
     {
       "import": "(data)/common/keep-internet-working.yaml"
@@ -45,5 +18,9 @@
       "action": "CHALLENGE"
     }
   ],
-  "dnsbl": false
-}
\ No newline at end of file
+  "dnsbl": false,
+  "status_codes": {
+    "CHALLENGE": 200,
+    "DENY": 200
+  }
+}
diff --git a/data/botPolicies.yaml b/data/botPolicies.yaml
index cd39780..e688491 100644
--- a/data/botPolicies.yaml
+++ b/data/botPolicies.yaml
@@ -12,21 +12,23 @@
 
 bots:
 # Pathological bots to deny
-- # This correlates to data/bots/ai-robots-txt.yaml in the source tree
-  import: (data)/bots/ai-robots-txt.yaml
-- import: (data)/bots/cloudflare-workers.yaml 
-- import: (data)/bots/headless-browsers.yaml
-- import: (data)/bots/us-ai-scraper.yaml
-
-# Search engines to allow
-- import: (data)/crawlers/googlebot.yaml
-- import: (data)/crawlers/bingbot.yaml
-- import: (data)/crawlers/duckduckbot.yaml
-- import: (data)/crawlers/qwantbot.yaml
-- import: (data)/crawlers/internet-archive.yaml
-- import: (data)/crawlers/kagibot.yaml
-- import: (data)/crawlers/marginalia.yaml
-- import: (data)/crawlers/mojeekbot.yaml
+- # This correlates to data/bots/deny-pathological.yaml in the source tree
+  # https://github.com/TecharoHQ/anubis/blob/main/data/bots/deny-pathological.yaml
+  import: (data)/bots/_deny-pathological.yaml
+
+# Enforce https://github.com/ai-robots-txt/ai.robots.txt
+- import: (data)/bots/ai-robots-txt.yaml
+
+# Search engine crawlers to allow, defaults to:
+#   - Google (so they don't try to bypass Anubis)
+#   - Bing
+#   - DuckDuckGo
+#   - Qwant
+#   - The Internet Archive
+#   - Kagi
+#   - Marginalia
+#   - Mojeek
+- import: (data)/crawlers/_allow-good.yaml
 
 # Allow common "keeping the internet working" routes (well-known, favicon, robots.txt)
 - import: (data)/common/keep-internet-working.yaml
diff --git a/data/bots/_deny-pathological.yaml b/data/bots/_deny-pathological.yaml
new file mode 100644
index 0000000..09d4bfc
--- /dev/null
+++ b/data/bots/_deny-pathological.yaml
@@ -0,0 +1,3 @@
+- import: (data)/bots/cloudflare-workers.yaml
+- import: (data)/bots/headless-browsers.yaml
+- import: (data)/bots/us-ai-scraper.yaml
\ No newline at end of file
diff --git a/data/crawlers/_allow-good.yaml b/data/crawlers/_allow-good.yaml
new file mode 100644
index 0000000..f95e176
--- /dev/null
+++ b/data/crawlers/_allow-good.yaml
@@ -0,0 +1,8 @@
+- import: (data)/crawlers/googlebot.yaml
+- import: (data)/crawlers/bingbot.yaml
+- import: (data)/crawlers/duckduckbot.yaml
+- import: (data)/crawlers/qwantbot.yaml
+- import: (data)/crawlers/internet-archive.yaml
+- import: (data)/crawlers/kagibot.yaml
+- import: (data)/crawlers/marginalia.yaml
+- import: (data)/crawlers/mojeekbot.yaml
\ No newline at end of file
diff --git a/data/embed.go b/data/embed.go
index ebb2152..3e5278f 100644
--- a/data/embed.go
+++ b/data/embed.go
@@ -3,6 +3,6 @@ package data
 import "embed"
 
 var (
-	//go:embed botPolicies.yaml botPolicies.json apps bots common crawlers
+	//go:embed botPolicies.yaml botPolicies.json all:apps all:bots all:common all:crawlers
 	BotPolicies embed.FS
 )
diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md
index d4d84e5..0666c32 100644
--- a/docs/docs/CHANGELOG.md
+++ b/docs/docs/CHANGELOG.md
@@ -11,8 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+- Fixed native packages not containing the stdlib and botPolicies.yaml
+
+## v1.17.1: Asahi sas Brutus: Echo 1
+
+- Added customization of authorization cookie expiration time with `--cookie-expiration-time` flag or envvar
 - Updated the `OG_PASSTHROUGH` to be true by default, thereby allowing OpenGraph tags to be passed through by default
 - Added the ability to [customize Anubis' HTTP status codes](./admin/configuration/custom-status-codes.mdx) ([#355](https://github.com/TecharoHQ/anubis/issues/355))
+- Change import syntax to allow multi-level imports
 
 ## v1.17.0: Asahi sas Brutus
 
diff --git a/docs/docs/admin/configuration/import.mdx b/docs/docs/admin/configuration/import.mdx
index 9934ce7..9a526e0 100644
--- a/docs/docs/admin/configuration/import.mdx
+++ b/docs/docs/admin/configuration/import.mdx
@@ -79,6 +79,45 @@ config.BotOrImport: rule definition is invalid, you must set either bot rules or
 
 Paths can either be prefixed with `(data)` to import from the [the data folder in the Anubis source tree](https://github.com/TecharoHQ/anubis/tree/main/data) or anywhere on the filesystem. If you don't have access to the Anubis source tree, check /usr/share/docs/anubis/data or in the tarball you extracted Anubis from.
 
+## Importing from imports
+
+You can also import from an imported file in case you want to import an entire folder of rules at once.
+
+<Tabs>
+<TabItem value="json" label="JSON">
+
+```json
+{
+  "bots": [
+    {
+      "import": "(data)/bots/_deny-pathological.yaml"
+    }
+  ]
+}
+```
+
+</TabItem>
+<TabItem value="yaml" label="YAML" default>
+
+```yaml
+bots:
+  - import: (data)/bots/_deny-pathological.yaml
+```
+
+</TabItem>
+</Tabs>
+
+This lets you import an entire ruleset at once:
+
+```yaml
+# (data)/bots/_deny-pathological.yaml
+- import: (data)/bots/cloudflare-workers.yaml
+- import: (data)/bots/headless-browsers.yaml
+- import: (data)/bots/us-ai-scraper.yaml
+```
+
+Use this with care, you can easily get yourself into a state where Anubis recursively imports things for eternity if you are not careful. The best way to use this is to make a "root import" named `_everything.yaml` or `_allow-good.yaml` so they sort to the top. Name your meta-imports after the main verb they are enforcing so that you can glance at the configuration file and understand what it's doing.
+
 ## Writing snippets
 
 Snippets can be written in either JSON or YAML, with a preference for YAML. When writing a snippet, write the bot rules you want directly at the top level of the file in a list.
diff --git a/docs/docs/admin/installation.mdx b/docs/docs/admin/installation.mdx
index 1fe2e0f..ac84d5c 100644
--- a/docs/docs/admin/installation.mdx
+++ b/docs/docs/admin/installation.mdx
@@ -55,6 +55,7 @@ Anubis uses these environment variables for configuration:
 | `BIND`                         | `:8923`                 | The network address that Anubis listens on. For `unix`, set this to a path: `/run/anubis/instance.sock`                                                                                                                                                                                                              |
 | `BIND_NETWORK`                 | `tcp`                   | The address family that Anubis listens on. Accepts `tcp`, `unix` and anything Go's [`net.Listen`](https://pkg.go.dev/net#Listen) supports.                                                                                                                                                                           |
 | `COOKIE_DOMAIN`                | unset                   | The domain the Anubis challenge pass cookie should be set to. This should be set to the domain you bought from your registrar (EG: `techaro.lol` if your webapp is running on `anubis.techaro.lol`). See [here](https://stackoverflow.com/a/1063760) for more information.                                           |
+| `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.                                                                                       |
 | `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.                                                                                                                           |
diff --git a/docs/docs/user/known-broken-extensions.md b/docs/docs/user/known-broken-extensions.md
index 0bcc6f9..864b5dc 100644
--- a/docs/docs/user/known-broken-extensions.md
+++ b/docs/docs/user/known-broken-extensions.md
@@ -3,17 +3,47 @@ title: List of known browser extensions that can break Anubis
 ---
 
 This page contains a list of all of the browser extensions that are known to break Anubis' functionality and their associated GitHub issues, along with instructions on how to work around the issue.
+
 ## [JShelter](https://jshelter.org/)
 
-| Extension    | JShelter                                      |
-| :----------- | :-------------------------------------------- |
-| Website      | [jshelter.org](https://jshelter.org/)         |
-| GitHub issue | https://github.com/TecharoHQ/anubis/issues/25 |
+| Extension    | JShelter                                                                                                                                           |
+| :----------- | :------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Website      | [jshelter.org](https://jshelter.org/)                                                                                                              |
+| GitHub issue | https://github.com/TecharoHQ/anubis/issues/25                                                                                                      |
+| Be aware of  | [What are Web Workers, and what are the threats that I face?](https://jshelter.org/faq/#what-are-web-workers-and-what-are-the-threats-that-i-face) |
+
+### Workaround steps (recommended):
+
+1. Click on the JShelter badge icon (typically in the toolbar next to your navigation bar; if you cannot locate the icon, see [this question](https://jshelter.org/faq/#can-i-see-a-jshelter-badge-icon-next-to-my-navigation-bar-i-want-to-interact-with-the-extension-easily-and-avoid-going-through-settings)).
+2. Expand JavaScript Shield settings by clicking on the `Modify` button.
+3. Click on the `Detail tweaks of JS shield for this site` button.
+4. Click and drag the `WebWorker` slider to the left until `Remove` is replaced by the `Unprotected`.
+5. Refresh the page, for example, by clicking on the `Refresh page` button at the top of the JShelter pop up window.
+6. You might want to restore the Worker settings once you go through the challenge.
+
+### Workaround steps (alternative if you do not want to dig in JShelter's pop up):
+
+1. Click on the JShelter badge icon (typically in the toolbar next to your navigation bar; if you cannot locate the icon, see [this question](https://jshelter.org/faq/#can-i-see-a-jshelter-badge-icon-next-to-my-navigation-bar-i-want-to-interact-with-the-extension-easily-and-avoid-going-through-settings)).
+2. Expand JavaScript Shield settings by clicking on the `Modify` button.
+3. Choose "Turn JavaScript Shield off"
+4. Refresh the page, for example, by clicking on the `Refresh page` button at the top of the JShelter pop up window.
+
+:::note
 
-Workaround steps:
+Taking these actions will remove all protections of JavaScript Shield for all pages at the visited web site. You might want review and amend your JavaScript shield settings once you go through the challenge based on your operational security model.
+
+:::
+
+### Workaround steps (alternative if you do not like JShelter's pop up):
 
 1. Open JShelter extension settings
 2. Click on JS Shield details
 3. Enter in the domain for a website protected by Anubis
 4. Choose "Turn JavaScript Shield off"
 5. Hit "Add to list"
+
+:::note
+
+Taking these actions will remove all protections of JavaScript Shield for all pages at the visited web site. You might want review and amend your JavaScript shield settings once you go through the challenge based on your operational security model.
+
+:::
diff --git a/docs/docs/user/known-instances.md b/docs/docs/user/known-instances.md
index eaa023d..5a59a89 100644
--- a/docs/docs/user/known-instances.md
+++ b/docs/docs/user/known-instances.md
@@ -31,6 +31,16 @@ This page contains a non-exhaustive list with all websites using Anubis.
 - https://hydra.nixos.org/
 - https://codeberg.org/
 - https://www.cfaarchive.org/
+- <details>
+  <summary>FreeCAD</summary>
+  - https://forum.freecad.org/
+  - https://wiki.freecad.org/
+  </details>
+- <details>
+  <summary>ScummVM</summary>
+  - https://forums.scummvm.org/
+  - https://wiki.scummvm.org/
+  </details>
 - <details>
   <summary>Sourceware</summary>
   - https://sourceware.org/cgit
diff --git a/lib/anubis.go b/lib/anubis.go
index 2a7add6..9f177b2 100644
--- a/lib/anubis.go
+++ b/lib/anubis.go
@@ -348,7 +348,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
 		"response":  response,
 		"iat":       time.Now().Unix(),
 		"nbf":       time.Now().Add(-1 * time.Minute).Unix(),
-		"exp":       time.Now().Add(24 * 7 * time.Hour).Unix(),
+		"exp":       time.Now().Add(s.opts.CookieExpiration).Unix(),
 	})
 	tokenString, err := token.SignedString(s.priv)
 	if err != nil {
@@ -361,7 +361,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
 	http.SetCookie(w, &http.Cookie{
 		Name:        anubis.CookieName,
 		Value:       tokenString,
-		Expires:     time.Now().Add(24 * 7 * time.Hour),
+		Expires:     time.Now().Add(s.opts.CookieExpiration),
 		SameSite:    http.SameSiteLaxMode,
 		Domain:      s.opts.CookieDomain,
 		Partitioned: s.opts.CookiePartitioned,
diff --git a/lib/config.go b/lib/config.go
index 44b6479..739e718 100644
--- a/lib/config.go
+++ b/lib/config.go
@@ -29,6 +29,7 @@ type Options struct {
 	ServeRobotsTXT  bool
 	PrivateKey      ed25519.PrivateKey
 
+	CookieExpiration  time.Duration
 	CookieDomain      string
 	CookieName        string
 	CookiePartitioned bool
diff --git a/lib/policy/config/config.go b/lib/policy/config/config.go
index 9dd61c9..2c51401 100644
--- a/lib/policy/config/config.go
+++ b/lib/policy/config/config.go
@@ -216,18 +216,27 @@ func (is *ImportStatement) load() error {
 	}
 	defer fin.Close()
 
+	var imported []BotOrImport
 	var result []BotConfig
 
-	if err := yaml.NewYAMLToJSONDecoder(fin).Decode(&result); err != nil {
+	if err := yaml.NewYAMLToJSONDecoder(fin).Decode(&imported); err != nil {
 		return fmt.Errorf("can't parse %s: %w", is.Import, err)
 	}
 
 	var errs []error
 
-	for _, b := range result {
+	for _, b := range imported {
 		if err := b.Valid(); err != nil {
 			errs = append(errs, err)
 		}
+
+		if b.ImportStatement != nil {
+			result = append(result, b.ImportStatement.Bots...)
+		}
+
+		if b.BotConfig != nil {
+			result = append(result, *b.BotConfig)
+		}
 	}
 
 	if len(errs) != 0 {
diff --git a/package.json b/package.json
index c88b2a0..776a4e4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@techaro/anubis",
-  "version": "1.17.0",
+  "version": "1.17.1",
   "description": "",
   "main": "index.js",
   "scripts": {
diff --git a/web/build.sh b/web/build.sh
index dfe6266..8b5f652 100755
--- a/web/build.sh
+++ b/web/build.sh
@@ -33,7 +33,7 @@ for the JavaScript code in this page.
 */'
 
 esbuild js/main.mjs --sourcemap --bundle --minify --outfile=static/js/main.mjs "--banner:js=${LICENSE}"
-gzip -f -k static/js/main.mjs
+gzip -f -k -n static/js/main.mjs
 zstd -f -k --ultra -22 static/js/main.mjs
 brotli -fZk static/js/main.mjs
 
diff --git a/yeetfile.js b/yeetfile.js
index 035de3c..b6ba804 100644
--- a/yeetfile.js
+++ b/yeetfile.js
@@ -11,17 +11,24 @@ $`npm run assets`;
         documentation: {
             "./README.md": "README.md",
             "./LICENSE": "LICENSE",
-            "./docs/docs/CHANGELOG.md": "CHANGELOG.md",
-            "./docs/docs/admin/policies.mdx": "policies.md",
-            "./docs/docs/admin/native-install.mdx": "native-install.mdx",
             "./data/botPolicies.json": "botPolicies.json",
+            "./data/botPolicies.yaml": "botPolicies.yaml",
         },
 
-        build: ({ bin, etc, systemd, out }) => {
+        build: ({ bin, etc, systemd, doc }) => {
             $`go build -o ${bin}/anubis -ldflags '-s -w -extldflags "-static" -X "github.com/TecharoHQ/anubis.Version=${git.tag()}"' ./cmd/anubis`;
 
             file.install("./run/anubis@.service", `${systemd}/anubis@.service`);
             file.install("./run/default.env", `${etc}/default.env`);
+
+            $`mkdir -p ${doc}/docs`
+            $`cp -a docs/docs ${doc}`;
+            $`find ${doc} -name _category_.json -delete`;
+            $`mkdir -p ${doc}/data`;
+            $`cp -a data/apps ${doc}/data/apps`;
+            $`cp -a data/bots ${doc}/data/bots`;
+            $`cp -a data/common ${doc}/data/common`;
+            $`cp -a data/crawlers ${doc}/data/crawlers`;
         },
     }));
 });



Test patch:
diff --git a/lib/anubis_test.go b/lib/anubis_test.go
index 63e76f7..2c401f5 100644
--- a/lib/anubis_test.go
+++ b/lib/anubis_test.go
@@ -8,6 +8,7 @@ import (
 	"os"
 	"strings"
 	"testing"
+	"time"
 
 	"github.com/TecharoHQ/anubis"
 	"github.com/TecharoHQ/anubis/data"
@@ -126,6 +127,96 @@ func TestCVE2025_24369(t *testing.T) {
 	}
 }
 
+func TestCookieCustomExpiration(t *testing.T) {
+	pol := loadPolicies(t, "")
+	pol.DefaultDifficulty = 0
+	ckieExpiration := 10 * time.Minute
+
+	srv := spawnAnubis(t, Options{
+		Next:   http.NewServeMux(),
+		Policy: pol,
+
+		CookieDomain:     "local.cetacean.club",
+		CookieName:       t.Name(),
+		CookieExpiration: ckieExpiration,
+	})
+
+	ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
+	defer ts.Close()
+
+	cli := &http.Client{
+		CheckRedirect: func(req *http.Request, via []*http.Request) error {
+			return http.ErrUseLastResponse
+		},
+	}
+
+	resp, err := cli.Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil)
+	if err != nil {
+		t.Fatalf("can't request challenge: %v", err)
+	}
+	defer resp.Body.Close()
+
+	var chall = struct {
+		Challenge string `json:"challenge"`
+	}{}
+	if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil {
+		t.Fatalf("can't read challenge response body: %v", err)
+	}
+
+	nonce := 0
+	elapsedTime := 420
+	redir := "/"
+	calculated := ""
+	calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce)
+	calculated = internal.SHA256sum(calcString)
+
+	req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", nil)
+	if err != nil {
+		t.Fatalf("can't make request: %v", err)
+	}
+
+	q := req.URL.Query()
+	q.Set("response", calculated)
+	q.Set("nonce", fmt.Sprint(nonce))
+	q.Set("redir", redir)
+	q.Set("elapsedTime", fmt.Sprint(elapsedTime))
+	req.URL.RawQuery = q.Encode()
+
+	requestRecieveLowerBound := time.Now()
+	resp, err = cli.Do(req)
+	requestRecieveUpperBound := time.Now()
+	if err != nil {
+		t.Fatalf("can't do challenge passing")
+	}
+
+	if resp.StatusCode != http.StatusFound {
+		resp.Write(os.Stderr)
+		t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
+	}
+
+	var ckie *http.Cookie
+	for _, cookie := range resp.Cookies() {
+		t.Logf("%#v", cookie)
+		if cookie.Name == anubis.CookieName {
+			ckie = cookie
+			break
+		}
+	}
+	if ckie == nil {
+		t.Errorf("Cookie %q not found", anubis.CookieName)
+		return
+	}
+
+	expirationLowerBound := requestRecieveLowerBound.Add(ckieExpiration)
+	expirationUpperBound := requestRecieveUpperBound.Add(ckieExpiration)
+	// Since the cookie expiration precision is only to the second due to the Unix() call, we can
+	// lower the level of expected precision.
+	if ckie.Expires.Unix() < expirationLowerBound.Unix() || ckie.Expires.Unix() > expirationUpperBound.Unix() {
+		t.Errorf("cookie expiration is not within the expected range. expected between: %v and %v. got: %v", expirationLowerBound, expirationUpperBound, ckie.Expires)
+		return
+	}
+}
+
 func TestCookieSettings(t *testing.T) {
 	pol := loadPolicies(t, "")
 	pol.DefaultDifficulty = 0
@@ -137,6 +228,7 @@ func TestCookieSettings(t *testing.T) {
 		CookieDomain:      "local.cetacean.club",
 		CookiePartitioned: true,
 		CookieName:        t.Name(),
+		CookieExpiration:  anubis.CookieDefaultExpirationTime,
 	})
 
 	ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
@@ -180,7 +272,9 @@ func TestCookieSettings(t *testing.T) {
 	q.Set("elapsedTime", fmt.Sprint(elapsedTime))
 	req.URL.RawQuery = q.Encode()
 
+	requestRecieveLowerBound := time.Now()
 	resp, err = cli.Do(req)
+	requestRecieveUpperBound := time.Now()
 	if err != nil {
 		t.Fatalf("can't do challenge passing")
 	}
@@ -207,6 +301,15 @@ func TestCookieSettings(t *testing.T) {
 		t.Errorf("cookie domain is wrong, wanted local.cetacean.club, got: %s", ckie.Domain)
 	}
 
+	expirationLowerBound := requestRecieveLowerBound.Add(anubis.CookieDefaultExpirationTime)
+	expirationUpperBound := requestRecieveUpperBound.Add(anubis.CookieDefaultExpirationTime)
+	// Since the cookie expiration precision is only to the second due to the Unix() call, we can
+	// lower the level of expected precision.
+	if ckie.Expires.Unix() < expirationLowerBound.Unix() || ckie.Expires.Unix() > expirationUpperBound.Unix() {
+		t.Errorf("cookie expiration is not within the expected range. expected between: %v and %v. got: %v", expirationLowerBound, expirationUpperBound, ckie.Expires)
+		return
+	}
+
 	if ckie.Partitioned != srv.opts.CookiePartitioned {
 		t.Errorf("wanted partitioned flag %v, got: %v", srv.opts.CookiePartitioned, ckie.Partitioned)
 	}