# Example cmdline (forward requests from upstream to port :8080) # $ go-away --bind :8080 --backend git.example.com=http://forgejo:3000 --policy examples/forgejo.yml --challenge-template forgejo --challenge-template-theme forgejo-auto # Define networks to be used later below networks: # todo: support direct ASN lookups # todo: cache these values huawei-cloud: # AS136907 - url: https://raw.githubusercontent.com/ipverse/asn-ip/refs/heads/master/as/136907/aggregated.json jq-path: '.subnets.ipv4[], .subnets.ipv6[]' alibaba-cloud: # AS45102 - url: https://raw.githubusercontent.com/ipverse/asn-ip/refs/heads/master/as/45102/aggregated.json jq-path: '.subnets.ipv4[], .subnets.ipv6[]' zenlayer-inc: # AS21859 - url: https://raw.githubusercontent.com/ipverse/asn-ip/refs/heads/master/as/21859/aggregated.json jq-path: '.subnets.ipv4[], .subnets.ipv6[]' aws-cloud: - url: https://ip-ranges.amazonaws.com/ip-ranges.json jq-path: '(.prefixes[] | select(has("ip_prefix")) | .ip_prefix), (.prefixes[] | select(has("ipv6_prefix")) | .ipv6_prefix)' google-cloud: - url: https://www.gstatic.com/ipranges/cloud.json jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)' oracle-cloud: - url: https://docs.oracle.com/en-us/iaas/tools/public_ip_ranges.json jq-path: '.regions[] | .cidrs[] | .cidr' azure-cloud: # todo: https://www.microsoft.com/en-us/download/details.aspx?id=56519 does not provide direct JSON - url: https://raw.githubusercontent.com/femueller/cloud-ip-ranges/refs/heads/master/microsoft-azure-ip-ranges.json jq-path: '.values[] | .properties.addressPrefixes[]' digitalocean: - url: https://www.digitalocean.com/geo/google.csv regex: "(?P(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+)," linode: - url: https://geoip.linode.com/ regex: "(?P(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+)," vultr: - url: "https://geofeed.constant.com/?json" jq-path: '.subnets[] | .ip_prefix' cloudflare: - url: https://www.cloudflare.com/ips-v4 regex: "(?P[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+/[0-9]+)" - url: https://www.cloudflare.com/ips-v6 regex: "(?P[0-9a-f:]+::/[0-9]+)" icloud-private-relay: - url: https://mask-api.icloud.com/egress-ip-ranges.csv regex: "(?P(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+)," tunnelbroker-relay: # HE Tunnelbroker - url: https://tunnelbroker.net/export/google regex: "(?P([0-9a-f:]+::)/[0-9]+)," googlebot: - url: https://developers.google.com/static/search/apis/ipranges/googlebot.json jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)' bingbot: - url: https://www.bing.com/toolbox/bingbot.json jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)' qwantbot: - url: https://help.qwant.com/wp-content/uploads/sites/2/2025/01/qwantbot.json jq-path: '(.prefixes[] | select(has("ipv4Prefix")) | .ipv4Prefix), (.prefixes[] | select(has("ipv6Prefix")) | .ipv6Prefix)' duckduckbot: - url: https://duckduckgo.com/duckduckgo-help-pages/results/duckduckbot regex: "
  • (?P[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)
  • " yandexbot: # todo: detected as bot # - url: https://yandex.com/ips # regex: "(?P(([0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+)|([0-9a-f:]+::))/[0-9]+)[ \\\\t]*
    " - prefixes: - "5.45.192.0/18" - "5.255.192.0/18" - "37.9.64.0/18" - "37.140.128.0/18" - "77.88.0.0/18" - "84.252.160.0/19" - "87.250.224.0/19" - "90.156.176.0/22" - "93.158.128.0/18" - "95.108.128.0/17" - "141.8.128.0/18" - "178.154.128.0/18" - "185.32.187.0/24" - "2a02:6b8::/29" kagibot: - url: https://kagi.com/bot regex: "\\n(?P[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+) " challenges: js-pow-sha256: # Asset must be under challenges/{name}/static/{asset} # Other files here will be available under that path mode: js asset: load.mjs parameters: difficulty: 20 runtime: mode: wasm # Verify must be under challenges/{name}/runtime/{asset} asset: runtime.wasm probability: 0.02 # Challenges with a cookie, self redirect (non-JS, requires HTTP parsing) self-cookie: mode: "cookie" # Challenges with a redirect via Link header with rel=preload and early hints (non-JS, requires HTTP parsing, fetching and logic) # Works on HTTP/2 and above! self-preload-link: condition: '"Sec-Fetch-Mode" in headers && headers["Sec-Fetch-Mode"] == "navigate"' mode: "preload-link" runtime: # verifies that result = key mode: "key" probability: 0.1 parameters: preload-early-hint-deadline: 3s key-code: 200 key-mime: text/css key-content: "" # Challenges with a redirect via Refresh header (non-JS, requires HTTP parsing and logic) self-header-refresh: mode: "header-refresh" runtime: # verifies that result = key mode: "key" probability: 0.1 # Challenges with a redirect via Refresh meta (non-JS, requires HTML parsing and logic) self-meta-refresh: mode: "meta-refresh" runtime: # verifies that result = key mode: "key" probability: 0.1 # Challenges with loading a random CSS or image document (non-JS, requires HTML parsing and logic) self-resource-load: mode: "resource-load" runtime: # verifies that result = key mode: "key" probability: 0.1 parameters: key-code: 200 key-mime: text/css key-content: "" # Verifies the existence of a cookie and confirms it against some backend request, passing the entire client cookie contents http-cookie-check: mode: http url: http://forgejo:3000/user/stopwatches # url: http://forgejo:3000/repo/search # url: http://forgejo:3000/notifications/new parameters: http-method: GET http-cookie: i_like_gitea http-code: 200 conditions: # Conditions will get replaced on rules AST when found as ($condition-name) # Checks to detect a headless chromium via headers only is-headless-chromium: - 'userAgent.contains("HeadlessChrome") || userAgent.contains("HeadlessChromium")' - '"Sec-Ch-Ua" in headers && (headers["Sec-Ch-Ua"].contains("HeadlessChrome") || headers["Sec-Ch-Ua"].contains("HeadlessChromium"))' #- '(userAgent.contains("Chrome/") || userAgent.contains("Chromium/")) && (!("Accept-Language" in headers) || !("Accept-Encoding" in headers))' is-generic-browser: - 'userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")' is-well-known-asset: - 'path == "/robots.txt"' - 'path.startsWith("/.well-known")' is-static-asset: - 'path == "/favicon.ico"' - 'path == "/apple-touch-icon.png"' - 'path == "/apple-touch-icon-precomposed.png"' - 'path.startsWith("/assets/")' - 'path.startsWith("/repo-avatars/")' - 'path.startsWith("/avatars/")' - 'path.startsWith("/avatar/")' - 'path.startsWith("/user/avatar/")' - 'path.startsWith("/attachments/")' is-git-ua: - 'userAgent.startsWith("git/") || userAgent.contains("libgit")' - 'userAgent.startsWith("go-git")' - 'userAgent.startsWith("JGit/") || userAgent.startsWith("JGit-")' # Golang proxy and initial fetch - 'userAgent.startsWith("GoModuleMirror/")' - 'userAgent.startsWith("Go-http-client/") && "go-get" in query && query["go-get"] == "1"' - '"Git-Protocol" in headers && headers["Git-Protocol"] == "version=2"' is-git-path: - 'path.matches("^/[^/]+/[^/]+/(git-upload-pack|git-receive-pack|HEAD|info/refs|info/lfs|objects)")' is-generic-robot-ua: - 'userAgent.contains("compatible;") && !userAgent.contains("Trident/")' - 'userAgent.matches("\\+https?://")' - 'userAgent.contains("@")' - 'userAgent.matches("[bB]ot/[0-9]")' is-tool-ua: - 'userAgent.startsWith("python-requests/")' - 'userAgent.startsWith("Python-urllib/")' - 'userAgent.startsWith("python-httpx/")' - 'userAgent.contains("aoihttp/")' - 'userAgent.startsWith("http.rb/")' - 'userAgent.startsWith("curl/")' - 'userAgent.startsWith("Wget/")' - 'userAgent.startsWith("libcurl/")' - 'userAgent.startsWith("okhttp/")' - 'userAgent.startsWith("Java/")' - 'userAgent.startsWith("Apache-HttpClient//")' - 'userAgent.startsWith("Go-http-client/")' - 'userAgent.startsWith("node-fetch/")' - 'userAgent.startsWith("reqwest/")' is-suspicious-crawler: # TLS Fingerprint for specific agent without ALPN - '(userAgent.startsWith("Mozilla/") || userAgent.startsWith("Opera/")) && fpJA4.matches("^t[0-9a-z]+00_")' # Old engines - 'userAgent.contains("Presto/") || userAgent.contains("Trident/")' # Old IE browsers - 'userAgent.matches("MSIE ([2-9]|10|11)\\.")' # Old Linux browsers - 'userAgent.matches("Linux i[63]86") || userAgent.matches("FreeBSD i[63]86")' # Old Windows browsers - 'userAgent.matches("Windows (3|95|98|CE)") || userAgent.matches("Windows NT [1-5]\\.")' # Old mobile browsers - 'userAgent.matches("Android [1-5]\\.") || userAgent.matches("(iPad|iPhone) OS [1-9]_")' # Old generic browsers - 'userAgent.startsWith("Opera/")' #- 'userAgent.matches("Gecko/(201[0-9]|200[0-9])")' - 'userAgent.matches("^Mozilla/[1-4]")' is-heavy-resource: - 'path.startsWith("/explore/")' - 'path.matches("^/[^/]+/[^/]+/src/commit/")' - 'path.matches("^/[^/]+/[^/]+/compare/")' - 'path.matches("^/[^/]+/[^/]+/commits/commit/")' - 'path.matches("^/[^/]+/[^/]+/blame/")' - 'path.matches("^/[^/]+/[^/]+/search/")' - 'path.matches("^/[^/]+/[^/]+/find/")' - 'path.matches("^/[^/]+/[^/]+/activity")' # any search with a custom query - '"q" in query && query.q != ""' # user activity tab - 'path.matches("^/[^/]+$") && "tab" in query && query.tab == "activity"' rules: - name: allow-well-known-resources conditions: - '($is-well-known-asset)' action: pass - name: allow-static-resources conditions: - '($is-static-asset)' action: pass - name: undesired-networks conditions: - 'inNetwork("huawei-cloud", remoteAddress) || inNetwork("alibaba-cloud", remoteAddress) || inNetwork("zenlayer-inc", remoteAddress)' action: poison - name: undesired-crawlers conditions: - '($is-headless-chromium)' - 'userAgent.startsWith("Lightpanda/")' - 'userAgent.startsWith("masscan/")' # Typo'd opera botnet - 'userAgent.matches("^Opera/[0-9.]+\\.\\(")' # AI bullshit stuff, they do not respect robots.txt even while they read it # TikTok Bytedance AI training - 'userAgent.contains("Bytedance") || userAgent.contains("Bytespider")' # Meta AI training; The Meta-ExternalAgent crawler crawls the web for use cases such as training AI models or improving products by indexing content directly. - 'userAgent.contains("meta-externalagent/") || userAgent.contains("meta-externalfetcher/") || userAgent.contains("FacebookBot")' # Anthropic AI training and usage - 'userAgent.contains("ClaudeBot") || userAgent.contains("Claude-User")|| userAgent.contains("Claude-SearchBot")' # Common Crawl AI crawlers - 'userAgent.contains("CCBot")' # ChatGPT AI crawlers https://platform.openai.com/docs/bots - 'userAgent.contains("GPTBot") || userAgent.contains("OAI-SearchBot") || userAgent.contains("ChatGPT-User")' # Other AI crawlers - 'userAgent.contains("Amazonbot") || userAgent.contains("Google-Extended") || userAgent.contains("PanguBot") || userAgent.contains("AI2Bot") || userAgent.contains("Diffbot") || userAgent.contains("cohere-training-data-crawler") || userAgent.contains("Applebot-Extended")' # SEO / Ads and marketing - 'userAgent.contains("BLEXBot")' action: poison - name: unknown-crawlers conditions: # No user agent set - 'userAgent == ""' action: deny # check a sequence of challenges for non logged in - name: suspicious-crawlers/0 conditions: ['($is-suspicious-crawler)'] action: check challenges: [js-pow-sha256, http-cookie-check] - name: suspicious-crawlers/1 conditions: ['($is-suspicious-crawler)'] action: check challenges: [self-preload-link] - name: suspicious-crawlers/2 conditions: ['($is-suspicious-crawler)'] action: check challenges: [self-header-refresh] - name: suspicious-crawlers/3 conditions: ['($is-suspicious-crawler)'] action: check challenges: [self-resource-load] - name: always-pow-challenge conditions: # login and sign up paths - 'path.startsWith("/user/sign_up")' - 'path.startsWith("/user/login") || path.startsWith("/user/oauth2/")' - 'path.startsWith("/user/activate")' # repo / org / mirror creation paths - 'path == "/repo/create" || path == "/repo/migrate" || path == "/org/create"' # user profile info edit paths - 'path == "/user/settings" || path.startsWith("/user/settings/hooks/")' # issue creation - 'path.matches("^/[^/]+/[^/]+/issues/new")' # Match archive downloads from browsers and not tools - 'path.matches("^/[^/]+/[^/]+/archive/.*\\.(bundle|zip|tar\\.gz)") && ($is-generic-browser)' action: challenge challenges: [ js-pow-sha256 ] - name: allow-git-operations conditions: - '($is-git-path)' - 'path.matches("^/[^/]+/[^/]+\\.git")' - 'path.matches("^/[^/]+/[^/]+/") && ($is-git-ua)' action: pass - name: sitemap conditions: - 'path == "/sitemap.xml" || path.matches("^/explore/(users|repos)/sitemap-[0-9]+\\.xml$")' action: pass # TODO: rss - name: api-call conditions: - 'path.startsWith("/api/v1/") || path.startsWith("/api/forgejo/v1/")' - 'path.startsWith("/login/oauth/")' - 'path.startsWith("/captcha/")' - 'path.startsWith("/metrics/")' # todo: post only - 'path == "/-/markup"' - 'path == "/user/events"' - 'path == "/ssh_info"' - 'path == "/api/healthz"' # actions - 'path.startsWith("/api/actions/") || path.startsWith("/api/actions_pipeline/")' # user pubkeys - 'path.matches("^/[^/]+\\.keys$")' - 'path.matches("^/[^/]+\\.gpg")' # OCI packages API and package managers - 'path.startsWith("/api/packages/") || path == "/api/packages"' - 'path.startsWith("/v2/") || path == "/v2"' action: pass - name: preview-fetchers conditions: - 'path.endsWith("/-/summary-card")' #- 'userAgent.contains("facebookexternalhit/")' - 'userAgent.contains("Twitterbot/")' - '"X-Purpose" in headers && headers["X-Purpose"] == "preview"' action: pass - name: desired-crawlers conditions: - 'userAgent.contains("+https://kagi.com/bot") && inNetwork("kagibot", remoteAddress)' - '(userAgent.contains("+http://www.google.com/bot.html") || userAgent.contains("Google-InspectionTool") || userAgent.contains("Googlebot")) && inNetwork("googlebot", remoteAddress)' - 'userAgent.contains("+http://www.bing.com/bingbot.htm") && inNetwork("bingbot", remoteAddress)' - 'userAgent.contains("+http://duckduckgo.com/duckduckbot.html") && inNetwork("duckduckbot", remoteAddress)' - 'userAgent.contains("+https://help.qwant.com/bot/") && inNetwork("qwantbot", remoteAddress)' - 'userAgent.contains("+http://yandex.com/bots") && inNetwork("yandexbot", remoteAddress)' action: pass - name: homesite conditions: - 'path == "/"' # generic /*/*/ match gave too many options for scrapers to trigger random endpoints # edit this with preferential users/orgs for now # todo: create negative match? - 'path.matches("(?i)^/(WeebDataHoarder|P2Pool|mirror|git|S\\.O\\.N\\.G|FM10K|Sillycom|pwgen2155|kaitou|metonym)/[^/]+$")' action: pass # check a sequence of challenges - name: heavy-operations/0 action: check challenges: [self-preload-link, self-header-refresh, js-pow-sha256, http-cookie-check] conditions: ['($is-heavy-resource)'] - name: heavy-operations/1 action: check challenges: [self-resource-load, js-pow-sha256, http-cookie-check] conditions: ['($is-heavy-resource)'] - name: standard-bots action: check challenges: [self-meta-refresh, self-resource-load] conditions: - '($is-generic-robot-ua)' # Allow all source downloads not caught in browser above # todo: limit this as needed? - name: source-download conditions: - 'path.matches("^/[^/]+/[^/]+/raw/branch/")' - 'path.matches("^/[^/]+/[^/]+/archive/")' - 'path.matches("^/[^/]+/[^/]+/releases/download/")' - 'path.matches("^/[^/]+/[^/]+/media/") && ($is-generic-browser)' action: pass # check DNSBL and serve harder challenges - name: undesired-dnsbl conditions: - 'inDNSBL(remoteAddress)' action: check challenges: [js-pow-sha256, http-cookie-check] - name: suspicious-fetchers action: check challenges: [js-pow-sha256] conditions: - 'userAgent.contains("facebookexternalhit/") || userAgent.contains("facebookcatalog/")' # Allow PUT/DELETE/PATCH/POST requests in general - name: non-get-request action: pass conditions: - '!(method == "HEAD" || method == "GET")' - name: plaintext-browser action: challenge challenges: [http-cookie-check, self-meta-refresh, self-cookie] conditions: - 'userAgent.startsWith("Lynx/")' - name: standard-tools action: challenge challenges: [self-cookie] conditions: - '($is-tool-ua)' - '!($is-generic-browser)' - name: standard-browser action: challenge challenges: [http-cookie-check, self-preload-link, self-meta-refresh, self-resource-load, js-pow-sha256] conditions: - '($is-generic-browser)'