redbot 2.4.2__tar.gz → 2.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {redbot-2.4.2/redbot.egg-info → redbot-2.5.0}/PKG-INFO +43 -1
- {redbot-2.4.2 → redbot-2.5.0}/README.md +40 -0
- {redbot-2.4.2 → redbot-2.5.0}/pyproject.toml +2 -37
- {redbot-2.4.2 → redbot-2.5.0}/redbot/__init__.py +1 -1
- {redbot-2.4.2 → redbot-2.5.0}/redbot/cli.py +25 -1
- {redbot-2.4.2 → redbot-2.5.0}/redbot/daemon.py +50 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/resource/fetch.py +66 -2
- redbot-2.5.0/redbot/webbotauth.py +244 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/handlers/run_test.py +4 -1
- {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/ratelimit.py +4 -1
- {redbot-2.4.2 → redbot-2.5.0/redbot.egg-info}/PKG-INFO +43 -1
- {redbot-2.4.2 → redbot-2.5.0}/redbot.egg-info/SOURCES.txt +1 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot.egg-info/requires.txt +2 -0
- {redbot-2.4.2 → redbot-2.5.0}/LICENSE.md +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/MANIFEST.in +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/__init__.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/icons/__init__.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/icons/check-circle.svg +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/icons/exclamation-circle.svg +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/icons/info-circle.svg +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/icons/question-circle.svg +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/icons/times-circle.svg +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/logo/__init__.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/logo/apple-touch-icon-144x144.png +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/logo/apple-touch-icon-152x152.png +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/logo/favicon-16x16.png +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/logo/favicon-32x32.png +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/logo/favicon.ico +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/logo/mstile-144x144.png +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/logo/redbot-sq.png +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/prettify.js +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/prettify.js.map +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/red_style.css +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/red_style.css.map +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/script.js +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/script.js.map +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/assets/style.css +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/formatter/__init__.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/formatter/har.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/formatter/html.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/formatter/html_base.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/formatter/null_links.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/formatter/templates/__init__.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/formatter/templates/footer.html +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/formatter/templates/response_finish.html +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/formatter/templates/response_multi_finish.html +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/formatter/templates/response_start.html +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/formatter/text.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/i18n.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/note.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/resource/__init__.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/resource/active_check/__init__.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/resource/active_check/base.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/resource/active_check/conneg.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/resource/active_check/etag_validate.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/resource/active_check/lm_validate.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/resource/active_check/range.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/resource/link_parse.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/translations/__init__.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/translations/es/LC_MESSAGES/messages.mo +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/translations/es/LC_MESSAGES/messages.po +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/translations/extra_strings.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/translations/fr/LC_MESSAGES/messages.po +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/translations/ja/LC_MESSAGES/messages.po +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/translations/messages.pot +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/translations/zh/LC_MESSAGES/messages.po +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/type.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/utils.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/__init__.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/captcha.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/handlers/__init__.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/handlers/base.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/handlers/client_error.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/handlers/error.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/handlers/save.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/handlers/show.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/links.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot/webui/saved_tests.py +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot.egg-info/dependency_links.txt +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot.egg-info/entry_points.txt +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/redbot.egg-info/top_level.txt +0 -0
- {redbot-2.4.2 → redbot-2.5.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: redbot
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.0
|
|
4
4
|
Summary: REDbot is lint for HTTP.
|
|
5
5
|
Author-email: Mark Nottingham <mnot@mnot.net>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -16,6 +16,8 @@ Classifier: Operating System :: MacOS :: MacOS X
|
|
|
16
16
|
Requires-Python: >=3.9
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
License-File: LICENSE.md
|
|
19
|
+
Requires-Dist: cryptography>=42.0.0
|
|
20
|
+
Requires-Dist: http-sf>=1.0
|
|
19
21
|
Requires-Dist: httplint>=2026.05.01
|
|
20
22
|
Requires-Dist: importlib_resources
|
|
21
23
|
Requires-Dist: Jinja2>=3.1.2
|
|
@@ -113,6 +115,46 @@ or
|
|
|
113
115
|
> podman run --rm -p 8000:8000 ghcr.io/mnot/redbot
|
|
114
116
|
|
|
115
117
|
|
|
118
|
+
## Web Bot Auth
|
|
119
|
+
|
|
120
|
+
REDbot can authenticate its outgoing requests using [Web Bot Auth](https://datatracker.ietf.org/doc/draft-meunier-web-bot-auth-architecture/), which signs requests with an Ed25519 key using [HTTP Message Signatures (RFC 9421)](https://www.rfc-editor.org/rfc/rfc9421). This lets origins verify that requests genuinely come from your REDbot instance. The implementation follows the IETF drafts and is not specific to any one verifier.
|
|
121
|
+
|
|
122
|
+
Requests are sent unsigned by default. REDbot only attaches a signature when the origin challenges for one -- that is, when it returns a `401`, `403`, or `429` response carrying an `Accept-Signature` header -- and then transparently retries the request signed. This avoids advertising the bot's identity to servers that don't ask for it.
|
|
123
|
+
|
|
124
|
+
### Generating a key
|
|
125
|
+
|
|
126
|
+
Create an Ed25519 private key in PEM form:
|
|
127
|
+
|
|
128
|
+
~~~ bash
|
|
129
|
+
> openssl genpkey -algorithm ed25519 -out web-bot-auth-key.pem
|
|
130
|
+
~~~
|
|
131
|
+
|
|
132
|
+
Keep this file private and readable by the REDbot process. The matching public key is published automatically (see below); you do not need to extract it yourself.
|
|
133
|
+
|
|
134
|
+
### Configuring
|
|
135
|
+
|
|
136
|
+
Set these in `config.txt` (for `redbot_daemon`):
|
|
137
|
+
|
|
138
|
+
~~~ ini
|
|
139
|
+
web_bot_auth_key = /path/to/web-bot-auth-key.pem
|
|
140
|
+
web_bot_auth_directory = https://your-redbot.example
|
|
141
|
+
~~~
|
|
142
|
+
|
|
143
|
+
`web_bot_auth_directory` is the HTTPS origin where your public key directory is hosted. When you run `redbot_daemon`, it serves that directory at `/.well-known/http-message-signatures-directory` (a JWKS, self-signed per the spec), so a verifier can fetch your public key. Make sure that path is reachable at the origin you configured.
|
|
144
|
+
|
|
145
|
+
`web_bot_auth_directory` is optional: if you omit it, REDbot uses the origin of `ui_uri`. Because `ui_uri` defaults to `https://redbot.org/`, **enabling signing requires `ui_uri` to be set to your instance's real public origin** — otherwise you would publish the wrong bot identity. Set `web_bot_auth_directory` explicitly if your key directory lives on a different origin than the UI.
|
|
146
|
+
|
|
147
|
+
For the command-line tool, pass the equivalent flags (there is no `ui_uri`, so the directory is required):
|
|
148
|
+
|
|
149
|
+
~~~ bash
|
|
150
|
+
> redbot --web-bot-auth-key web-bot-auth-key.pem --web-bot-auth-directory https://your-redbot.example https://example.com/
|
|
151
|
+
~~~
|
|
152
|
+
|
|
153
|
+
### Registering with a verifier
|
|
154
|
+
|
|
155
|
+
To be recognised by a specific verifier, register your directory URL with them according to their process.
|
|
156
|
+
|
|
157
|
+
|
|
116
158
|
## Credits
|
|
117
159
|
|
|
118
160
|
Icons by [Font Awesome](https://fontawesome.com/). REDbot includes code from [tippy.js](https://atomiks.github.io/tippyjs/) and [prettify.js](https://github.com/google/code-prettify).
|
|
@@ -67,6 +67,46 @@ or
|
|
|
67
67
|
> podman run --rm -p 8000:8000 ghcr.io/mnot/redbot
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
## Web Bot Auth
|
|
71
|
+
|
|
72
|
+
REDbot can authenticate its outgoing requests using [Web Bot Auth](https://datatracker.ietf.org/doc/draft-meunier-web-bot-auth-architecture/), which signs requests with an Ed25519 key using [HTTP Message Signatures (RFC 9421)](https://www.rfc-editor.org/rfc/rfc9421). This lets origins verify that requests genuinely come from your REDbot instance. The implementation follows the IETF drafts and is not specific to any one verifier.
|
|
73
|
+
|
|
74
|
+
Requests are sent unsigned by default. REDbot only attaches a signature when the origin challenges for one -- that is, when it returns a `401`, `403`, or `429` response carrying an `Accept-Signature` header -- and then transparently retries the request signed. This avoids advertising the bot's identity to servers that don't ask for it.
|
|
75
|
+
|
|
76
|
+
### Generating a key
|
|
77
|
+
|
|
78
|
+
Create an Ed25519 private key in PEM form:
|
|
79
|
+
|
|
80
|
+
~~~ bash
|
|
81
|
+
> openssl genpkey -algorithm ed25519 -out web-bot-auth-key.pem
|
|
82
|
+
~~~
|
|
83
|
+
|
|
84
|
+
Keep this file private and readable by the REDbot process. The matching public key is published automatically (see below); you do not need to extract it yourself.
|
|
85
|
+
|
|
86
|
+
### Configuring
|
|
87
|
+
|
|
88
|
+
Set these in `config.txt` (for `redbot_daemon`):
|
|
89
|
+
|
|
90
|
+
~~~ ini
|
|
91
|
+
web_bot_auth_key = /path/to/web-bot-auth-key.pem
|
|
92
|
+
web_bot_auth_directory = https://your-redbot.example
|
|
93
|
+
~~~
|
|
94
|
+
|
|
95
|
+
`web_bot_auth_directory` is the HTTPS origin where your public key directory is hosted. When you run `redbot_daemon`, it serves that directory at `/.well-known/http-message-signatures-directory` (a JWKS, self-signed per the spec), so a verifier can fetch your public key. Make sure that path is reachable at the origin you configured.
|
|
96
|
+
|
|
97
|
+
`web_bot_auth_directory` is optional: if you omit it, REDbot uses the origin of `ui_uri`. Because `ui_uri` defaults to `https://redbot.org/`, **enabling signing requires `ui_uri` to be set to your instance's real public origin** — otherwise you would publish the wrong bot identity. Set `web_bot_auth_directory` explicitly if your key directory lives on a different origin than the UI.
|
|
98
|
+
|
|
99
|
+
For the command-line tool, pass the equivalent flags (there is no `ui_uri`, so the directory is required):
|
|
100
|
+
|
|
101
|
+
~~~ bash
|
|
102
|
+
> redbot --web-bot-auth-key web-bot-auth-key.pem --web-bot-auth-directory https://your-redbot.example https://example.com/
|
|
103
|
+
~~~
|
|
104
|
+
|
|
105
|
+
### Registering with a verifier
|
|
106
|
+
|
|
107
|
+
To be recognised by a specific verifier, register your directory URL with them according to their process.
|
|
108
|
+
|
|
109
|
+
|
|
70
110
|
## Credits
|
|
71
111
|
|
|
72
112
|
Icons by [Font Awesome](https://fontawesome.com/). REDbot includes code from [tippy.js](https://atomiks.github.io/tippyjs/) and [prettify.js](https://github.com/google/code-prettify).
|
|
@@ -20,6 +20,8 @@ classifiers = [
|
|
|
20
20
|
]
|
|
21
21
|
license-files = ["LICENSE.md"]
|
|
22
22
|
dependencies = [
|
|
23
|
+
"cryptography >= 42.0.0",
|
|
24
|
+
"http-sf >= 1.0",
|
|
23
25
|
"httplint >= 2026.05.01",
|
|
24
26
|
"importlib_resources",
|
|
25
27
|
"Jinja2 >= 3.1.2",
|
|
@@ -74,42 +76,5 @@ namespaces = false
|
|
|
74
76
|
[tool.setuptools]
|
|
75
77
|
include-package-data = true
|
|
76
78
|
|
|
77
|
-
[tool.mypy]
|
|
78
|
-
strict = true
|
|
79
|
-
show_error_codes = true
|
|
80
|
-
|
|
81
|
-
[tool.pylint.basic]
|
|
82
|
-
function-rgx = "[a-z_][a-z0-9_]{1,30}$"
|
|
83
|
-
variable-rgx = "[a-z_][a-z0-9_]{1,30}$"
|
|
84
|
-
attr-rgx = "[a-z_][a-z0-9_]{1,30}$"
|
|
85
|
-
argument-rgx = "[a-z_][a-z0-9_]{1,30}$"
|
|
86
|
-
class-attribute-rgx = "([A-Za-z_][A-Za-z0-9_]{1,30}|(__.*__))$"
|
|
87
|
-
method-rgx = "[a-z_][a-z0-9_]{1,30}$"
|
|
88
|
-
class-rgx = "[a-zA-Z0-9_]+$"
|
|
89
|
-
|
|
90
|
-
[tool.pylint.messages_control]
|
|
91
|
-
disable = "C0114,C0115,C0116,W0613,R0903"
|
|
92
|
-
|
|
93
|
-
[tool.pylint.reports]
|
|
94
|
-
reports = false
|
|
95
|
-
|
|
96
|
-
[tool.pylint.variables]
|
|
97
|
-
dummy-variables-rgx = "response_start|response_body|response_done|error|formatter_done|check_done|_"
|
|
98
|
-
|
|
99
|
-
[tool.pylint.design]
|
|
100
|
-
max-args=10
|
|
101
|
-
max-positional-arguments=10
|
|
102
|
-
max-locals=35
|
|
103
|
-
max-branches=25
|
|
104
|
-
max-statements=100
|
|
105
|
-
max-attributes=40
|
|
106
|
-
min-public-methods=1
|
|
107
|
-
|
|
108
|
-
[tool.pylint.similarities]
|
|
109
|
-
min-similarity-lines = 10
|
|
110
|
-
|
|
111
79
|
[tool.black]
|
|
112
80
|
line-length = 100
|
|
113
|
-
|
|
114
|
-
[tool.isort]
|
|
115
|
-
profile = "black"
|
|
@@ -12,6 +12,7 @@ import thor
|
|
|
12
12
|
|
|
13
13
|
from redbot.formatter import available_formatters, find_formatter
|
|
14
14
|
from redbot.resource import HttpResource
|
|
15
|
+
from redbot.webbotauth import WebBotAuthError, load_signer
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
def main() -> None:
|
|
@@ -38,12 +39,35 @@ def main() -> None:
|
|
|
38
39
|
default="text",
|
|
39
40
|
help="output format",
|
|
40
41
|
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--web-bot-auth-key",
|
|
44
|
+
action="store",
|
|
45
|
+
dest="web_bot_auth_key",
|
|
46
|
+
help="path to an Ed25519 private key (PEM) for signing requests with Web Bot Auth",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--web-bot-auth-directory",
|
|
50
|
+
action="store",
|
|
51
|
+
dest="web_bot_auth_directory",
|
|
52
|
+
help="HTTPS origin hosting this bot's key directory (the Signature-Agent value)",
|
|
53
|
+
)
|
|
41
54
|
args = parser.parse_args()
|
|
42
55
|
|
|
56
|
+
redbot_config = {"enable_local_access": "True"}
|
|
57
|
+
if args.web_bot_auth_key:
|
|
58
|
+
redbot_config["web_bot_auth_key"] = args.web_bot_auth_key
|
|
59
|
+
if args.web_bot_auth_directory:
|
|
60
|
+
redbot_config["web_bot_auth_directory"] = args.web_bot_auth_directory
|
|
43
61
|
config_parser = ConfigParser()
|
|
44
|
-
config_parser.read_dict({"redbot":
|
|
62
|
+
config_parser.read_dict({"redbot": redbot_config})
|
|
45
63
|
config = config_parser["redbot"]
|
|
46
64
|
|
|
65
|
+
try:
|
|
66
|
+
load_signer(config) # validate Web Bot Auth config up front
|
|
67
|
+
except WebBotAuthError as why:
|
|
68
|
+
sys.stderr.write(f"Web Bot Auth configuration error: {why}\n")
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
|
|
47
71
|
resource = HttpResource(config, descend=args.descend)
|
|
48
72
|
resource.set_request(args.url)
|
|
49
73
|
|
|
@@ -34,6 +34,13 @@ from thor.tcp import TcpConnection
|
|
|
34
34
|
|
|
35
35
|
import redbot
|
|
36
36
|
from redbot.type import RawHeaderListType
|
|
37
|
+
from redbot.webbotauth import (
|
|
38
|
+
DIRECTORY_CONTENT_TYPE,
|
|
39
|
+
DIRECTORY_MAX_AGE,
|
|
40
|
+
DIRECTORY_PATH,
|
|
41
|
+
WebBotAuthError,
|
|
42
|
+
load_signer,
|
|
43
|
+
)
|
|
37
44
|
from redbot.webui import RedWebUi
|
|
38
45
|
from redbot.webui.saved_tests import clean_saved_tests
|
|
39
46
|
|
|
@@ -78,6 +85,15 @@ class RedBotServer:
|
|
|
78
85
|
if SYSTEMD_NOTIFIER is not None:
|
|
79
86
|
thor.schedule(self.watchdog_freq, self.watchdog_ping)
|
|
80
87
|
|
|
88
|
+
# Set up Web Bot Auth signing (validate config up front).
|
|
89
|
+
try:
|
|
90
|
+
self.signer = load_signer(config)
|
|
91
|
+
except WebBotAuthError as why:
|
|
92
|
+
self.console(f"FATAL: Web Bot Auth configuration error: {why}")
|
|
93
|
+
sys.exit(1)
|
|
94
|
+
if self.signer is not None:
|
|
95
|
+
self.console(f"Web Bot Auth enabled (keyid {self.signer.keyid})")
|
|
96
|
+
|
|
81
97
|
self.static_files = resource_files("redbot.assets")
|
|
82
98
|
self.extra_files = {}
|
|
83
99
|
extra_base_dir = self.config.get("extra_base_dir", None)
|
|
@@ -342,6 +358,8 @@ class RedRequestHandler:
|
|
|
342
358
|
p_uri = urlsplit(self.uri)
|
|
343
359
|
except UnicodeDecodeError:
|
|
344
360
|
return self.bad_request(b"That's not a URL.")
|
|
361
|
+
if p_uri.path == DIRECTORY_PATH.encode("ascii"):
|
|
362
|
+
return self.serve_directory()
|
|
345
363
|
if (
|
|
346
364
|
p_uri.path.startswith(self.server.static_root + b"/")
|
|
347
365
|
or p_uri.path in self.server.extra_files
|
|
@@ -407,6 +425,38 @@ in standalone server mode. Details follow.
|
|
|
407
425
|
self.exchange.response_done([])
|
|
408
426
|
return None
|
|
409
427
|
|
|
428
|
+
def serve_directory(self) -> None:
|
|
429
|
+
"Serve the Web Bot Auth key directory (a self-signed JWKS)."
|
|
430
|
+
signer = self.server.signer
|
|
431
|
+
if signer is None:
|
|
432
|
+
return self.not_found(DIRECTORY_PATH.encode("ascii"))
|
|
433
|
+
body = signer.directory_json()
|
|
434
|
+
headers = [
|
|
435
|
+
(b"Content-Type", DIRECTORY_CONTENT_TYPE.encode("ascii")),
|
|
436
|
+
(b"Cache-Control", b"max-age=%d" % DIRECTORY_MAX_AGE),
|
|
437
|
+
]
|
|
438
|
+
authority = self.request_authority()
|
|
439
|
+
if authority:
|
|
440
|
+
headers += signer.sign_directory(authority)
|
|
441
|
+
self.exchange.response_start(b"200", b"OK", headers)
|
|
442
|
+
self.exchange.response_body(body)
|
|
443
|
+
self.exchange.response_done([])
|
|
444
|
+
return None
|
|
445
|
+
|
|
446
|
+
def request_authority(self) -> str:
|
|
447
|
+
"The authority (Host) of the incoming request, for signing."
|
|
448
|
+
for name, value in self.req_hdrs:
|
|
449
|
+
if name.lower() == b"host":
|
|
450
|
+
host = value.decode("ascii", "replace").strip().lower()
|
|
451
|
+
# Drop a default port so the signed @authority matches what a
|
|
452
|
+
# verifier normalizes to (RFC 9421 derives @authority this way).
|
|
453
|
+
for suffix in (":443", ":80"):
|
|
454
|
+
if host.endswith(suffix):
|
|
455
|
+
host = host[: -len(suffix)]
|
|
456
|
+
break
|
|
457
|
+
return host
|
|
458
|
+
return ""
|
|
459
|
+
|
|
410
460
|
def not_found(self, path: bytes) -> None:
|
|
411
461
|
headers = []
|
|
412
462
|
headers.append((b"Content-Type", b"text/plain"))
|
|
@@ -21,8 +21,28 @@ from redbot import __version__
|
|
|
21
21
|
from redbot.i18n import _
|
|
22
22
|
from redbot.note import RedbotNote
|
|
23
23
|
from redbot.type import RawHeaderListType, StrHeaderListType
|
|
24
|
+
from redbot.webbotauth import WebBotAuthError, load_signer
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
# Fallback UI URL advertised in the User-Agent when ui_uri is not configured
|
|
27
|
+
# (e.g. the CLI). The public instance's ui_uri defaults to the same value.
|
|
28
|
+
DEFAULT_UI_URI = "https://redbot.org/"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def ua_string(config: SectionProxy) -> bytes:
|
|
32
|
+
"The User-Agent for outgoing requests, pointing at this instance's UI URL."
|
|
33
|
+
uri = config.get("ui_uri", "").strip() or DEFAULT_UI_URI
|
|
34
|
+
return f"RED/{__version__} ({uri})".encode("ascii", "replace")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Status codes on which a response carrying an Accept-Signature header is treated
|
|
38
|
+
# as a Web Bot Auth challenge to re-send the request signed
|
|
39
|
+
# (draft-meunier-web-bot-auth-architecture, Section 4.3).
|
|
40
|
+
WBA_CHALLENGE_STATUSES = {b"401", b"403", b"429"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _has_accept_signature(res_headers: RawHeaderListType) -> bool:
|
|
44
|
+
"Whether the response asks the client to sign (RFC 9421 Accept-Signature)."
|
|
45
|
+
return any(name.lower() == b"accept-signature" for name, _v in res_headers)
|
|
26
46
|
|
|
27
47
|
|
|
28
48
|
class RedHttpClient(thor.http.HttpClient):
|
|
@@ -74,6 +94,7 @@ class RedFetcher(thor.events.EventEmitter):
|
|
|
74
94
|
self.fetch_started = False
|
|
75
95
|
self.fetch_error: Optional[httperr.HttpError] = None
|
|
76
96
|
self.fetch_done = False
|
|
97
|
+
self._wba_retried = False
|
|
77
98
|
self.setup_check_ip()
|
|
78
99
|
|
|
79
100
|
def __getstate__(self) -> Dict[str, Any]:
|
|
@@ -159,7 +180,15 @@ class RedFetcher(thor.events.EventEmitter):
|
|
|
159
180
|
assert self.request.uri, "uri not set in check"
|
|
160
181
|
|
|
161
182
|
if "user-agent" not in [i[0].lower() for i in self.request.headers.text]:
|
|
162
|
-
self.request.headers.process([(b"User-Agent",
|
|
183
|
+
self.request.headers.process([(b"User-Agent", ua_string(self.config))])
|
|
184
|
+
# Requests are sent unsigned; Web Bot Auth signatures are only added when
|
|
185
|
+
# the origin challenges for them (see _response_start).
|
|
186
|
+
self._send_request()
|
|
187
|
+
|
|
188
|
+
def _send_request(self, extra_headers: Optional[RawHeaderListType] = None) -> None:
|
|
189
|
+
"Start an exchange for the current request, optionally adding extra headers."
|
|
190
|
+
assert self.request.method, "method not set in _send_request"
|
|
191
|
+
assert self.request.uri, "uri not set in _send_request"
|
|
163
192
|
self.exchange = self.client.exchange()
|
|
164
193
|
self.exchange.on("response_nonfinal", self._response_nonfinal)
|
|
165
194
|
self.exchange.once("response_start", self._response_start)
|
|
@@ -176,6 +205,8 @@ class RedFetcher(thor.events.EventEmitter):
|
|
|
176
205
|
(k.encode("ascii", "replace"), v.encode("ascii", "replace"))
|
|
177
206
|
for (k, v) in self.request.headers.text
|
|
178
207
|
]
|
|
208
|
+
if extra_headers:
|
|
209
|
+
req_hdrs += extra_headers
|
|
179
210
|
self.request.start_time = time.time()
|
|
180
211
|
self.exchange.request_start(
|
|
181
212
|
self.request.method.encode("ascii"),
|
|
@@ -189,6 +220,19 @@ class RedFetcher(thor.events.EventEmitter):
|
|
|
189
220
|
if not self.fetch_done: # the request could have immediately failed.
|
|
190
221
|
self.exchange.request_done([])
|
|
191
222
|
|
|
223
|
+
def _abort_exchange(self) -> None:
|
|
224
|
+
"Detach from and close the current exchange (e.g. to retry the request)."
|
|
225
|
+
if hasattr(self, "exchange"):
|
|
226
|
+
self.exchange.remove_listeners(
|
|
227
|
+
"response_nonfinal",
|
|
228
|
+
"response_start",
|
|
229
|
+
"response_body",
|
|
230
|
+
"response_done",
|
|
231
|
+
"error",
|
|
232
|
+
)
|
|
233
|
+
if self.exchange.conn:
|
|
234
|
+
self.exchange.conn.close()
|
|
235
|
+
|
|
192
236
|
def _response_nonfinal(
|
|
193
237
|
self, status: bytes, phrase: bytes, res_headers: RawHeaderListType
|
|
194
238
|
) -> None:
|
|
@@ -219,6 +263,26 @@ class RedFetcher(thor.events.EventEmitter):
|
|
|
219
263
|
"Process the response start-line and headers."
|
|
220
264
|
if self.fetch_done:
|
|
221
265
|
return
|
|
266
|
+
if not self._wba_retried and status in WBA_CHALLENGE_STATUSES and self.request.uri:
|
|
267
|
+
if _has_accept_signature(res_headers):
|
|
268
|
+
try:
|
|
269
|
+
signer = load_signer(self.config)
|
|
270
|
+
except WebBotAuthError as why:
|
|
271
|
+
# A key problem arising mid-run (e.g. rotated to a bad file)
|
|
272
|
+
# should not crash the fetch; just proceed unsigned.
|
|
273
|
+
self.emit("debug", f"Web Bot Auth disabled for this request: {why}")
|
|
274
|
+
signer = None
|
|
275
|
+
if signer is not None:
|
|
276
|
+
self._wba_retried = True
|
|
277
|
+
self.emit(
|
|
278
|
+
"debug",
|
|
279
|
+
f"Web Bot Auth challenge for {self.request.uri}; retrying signed",
|
|
280
|
+
)
|
|
281
|
+
self._abort_exchange()
|
|
282
|
+
self.nonfinal_responses = []
|
|
283
|
+
self.transfer_out = 0
|
|
284
|
+
self._send_request(signer.sign_request(self.request.uri))
|
|
285
|
+
return
|
|
222
286
|
self.response.start_time = time.time()
|
|
223
287
|
assert self.exchange.res_version, "exchange.res_version not set in _response_start"
|
|
224
288
|
self.response.process_response_topline(self.exchange.res_version, status, phrase)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Web Bot Auth support for REDbot.
|
|
3
|
+
|
|
4
|
+
Web Bot Auth lets an automated client prove its identity to an origin by
|
|
5
|
+
attaching an HTTP Message Signature (RFC 9421) to its requests, signed with an
|
|
6
|
+
Ed25519 key whose public half is published in a directory at a well-known
|
|
7
|
+
location.
|
|
8
|
+
|
|
9
|
+
This module implements the IETF drafts
|
|
10
|
+
(draft-meunier-web-bot-auth-architecture and
|
|
11
|
+
draft-meunier-http-message-signatures-directory) and is not specific to any
|
|
12
|
+
particular verifier.
|
|
13
|
+
|
|
14
|
+
It provides:
|
|
15
|
+
|
|
16
|
+
* ``WebBotAuthSigner`` - signs outgoing requests and the directory response.
|
|
17
|
+
* ``load_signer`` - builds (and caches) a signer from REDbot configuration.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import base64
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import secrets
|
|
25
|
+
import time
|
|
26
|
+
from configparser import SectionProxy
|
|
27
|
+
from typing import Dict, List, Optional, Tuple
|
|
28
|
+
from urllib.parse import urlsplit
|
|
29
|
+
|
|
30
|
+
import http_sf
|
|
31
|
+
from cryptography.hazmat.primitives import serialization
|
|
32
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
33
|
+
|
|
34
|
+
# The tag identifying the purpose of a signature (draft-meunier-web-bot-auth).
|
|
35
|
+
REQUEST_TAG = "web-bot-auth"
|
|
36
|
+
# The tag for a signature over the directory response itself.
|
|
37
|
+
DIRECTORY_TAG = "http-message-signatures-directory"
|
|
38
|
+
# Where the key directory is served, relative to the origin root.
|
|
39
|
+
DIRECTORY_PATH = "/.well-known/http-message-signatures-directory"
|
|
40
|
+
# Media type for the key directory.
|
|
41
|
+
DIRECTORY_CONTENT_TYPE = "application/http-message-signatures-directory+json"
|
|
42
|
+
|
|
43
|
+
# Default lifetime of a request signature, in seconds. Web Bot Auth allows up to
|
|
44
|
+
# 24h; we keep it short to limit the window for replay.
|
|
45
|
+
DEFAULT_VALIDITY = 300
|
|
46
|
+
# Lifetime of the directory response signature. The directory is cacheable for a
|
|
47
|
+
# day (see DIRECTORY_MAX_AGE), so its signature must stay valid at least that
|
|
48
|
+
# long, or a verifier re-checking a cached copy would see it expired.
|
|
49
|
+
DIRECTORY_VALIDITY = 86400
|
|
50
|
+
# Cache lifetime advertised for the directory, in seconds.
|
|
51
|
+
DIRECTORY_MAX_AGE = 86400
|
|
52
|
+
# The label tying together the Signature-Input and Signature dictionary members.
|
|
53
|
+
SIG_LABEL = "sig1"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class WebBotAuthError(Exception):
|
|
57
|
+
"Raised when Web Bot Auth is misconfigured."
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _b64url(data: bytes) -> str:
|
|
61
|
+
"base64url-encode without padding (RFC 7515 / RFC 8037)."
|
|
62
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class WebBotAuthSigner:
|
|
66
|
+
"""
|
|
67
|
+
Signs requests and directory responses for Web Bot Auth.
|
|
68
|
+
|
|
69
|
+
``key_pem`` is an Ed25519 private key in PEM (PKCS#8) form. ``directory`` is
|
|
70
|
+
the HTTPS origin that hosts this bot's key directory; it becomes the
|
|
71
|
+
Signature-Agent header value, and verifiers append the well-known directory
|
|
72
|
+
path to it. ``validity`` is the signature lifetime in seconds.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
key_pem: bytes,
|
|
78
|
+
directory: str,
|
|
79
|
+
validity: int = DEFAULT_VALIDITY,
|
|
80
|
+
key_created: Optional[int] = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
try:
|
|
83
|
+
key = serialization.load_pem_private_key(key_pem, password=None)
|
|
84
|
+
except (ValueError, TypeError) as why:
|
|
85
|
+
raise WebBotAuthError(f"Couldn't load Web Bot Auth key: {why}") from why
|
|
86
|
+
if not isinstance(key, Ed25519PrivateKey):
|
|
87
|
+
raise WebBotAuthError("Web Bot Auth key must be an Ed25519 private key.")
|
|
88
|
+
self._key = key
|
|
89
|
+
self.directory = directory.strip().strip('"')
|
|
90
|
+
self.validity = validity
|
|
91
|
+
self.key_created = key_created
|
|
92
|
+
|
|
93
|
+
raw_public = key.public_key().public_bytes(
|
|
94
|
+
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
95
|
+
)
|
|
96
|
+
self.public_x = _b64url(raw_public)
|
|
97
|
+
# RFC 8037 Appendix A.3 / RFC 7638: thumbprint over the canonical JWK
|
|
98
|
+
# (members lexicographically ordered, no whitespace).
|
|
99
|
+
thumb_input = json.dumps(
|
|
100
|
+
{"crv": "Ed25519", "kty": "OKP", "x": self.public_x},
|
|
101
|
+
separators=(",", ":"),
|
|
102
|
+
sort_keys=True,
|
|
103
|
+
).encode("ascii")
|
|
104
|
+
self.keyid = _b64url(hashlib.sha256(thumb_input).digest())
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def authority(uri: str) -> str:
|
|
108
|
+
"The RFC 9421 @authority derived component for a target URI."
|
|
109
|
+
parts = urlsplit(uri)
|
|
110
|
+
host = (parts.hostname or "").lower()
|
|
111
|
+
default = {"http": 80, "https": 443}.get(parts.scheme.lower())
|
|
112
|
+
try:
|
|
113
|
+
port = parts.port
|
|
114
|
+
except ValueError:
|
|
115
|
+
port = None
|
|
116
|
+
if port is not None and port != default:
|
|
117
|
+
return f"{host}:{port}"
|
|
118
|
+
return host
|
|
119
|
+
|
|
120
|
+
def _build_signature(
|
|
121
|
+
self,
|
|
122
|
+
components: List[Tuple[str, str]],
|
|
123
|
+
tag: str,
|
|
124
|
+
validity: int,
|
|
125
|
+
) -> Tuple[str, str]:
|
|
126
|
+
"""
|
|
127
|
+
Construct the RFC 9421 signature base over ``components`` (each an
|
|
128
|
+
ordered (component-name, component-value) pair), sign it, and return the
|
|
129
|
+
Signature-Input and Signature dictionary-member values for SIG_LABEL.
|
|
130
|
+
``validity`` is the signature lifetime in seconds.
|
|
131
|
+
|
|
132
|
+
Structured-field values are serialized with ``http_sf`` so the
|
|
133
|
+
@signature-params line in the base and the Signature-Input header are
|
|
134
|
+
produced identically.
|
|
135
|
+
"""
|
|
136
|
+
created = int(time.time())
|
|
137
|
+
params: http_sf.types.ParamsType = {
|
|
138
|
+
"created": created,
|
|
139
|
+
"expires": created + validity,
|
|
140
|
+
"keyid": self.keyid,
|
|
141
|
+
"alg": "ed25519",
|
|
142
|
+
"nonce": base64.b64encode(secrets.token_bytes(32)).decode("ascii"),
|
|
143
|
+
"tag": tag,
|
|
144
|
+
}
|
|
145
|
+
covered: List[http_sf.types.ItemType] = [name for name, _ in components]
|
|
146
|
+
inner_list: http_sf.types.InnerListType = (covered, params)
|
|
147
|
+
|
|
148
|
+
base_lines = [f"{http_sf.ser(name)}: {value}" for name, value in components]
|
|
149
|
+
base_lines.append(f'{http_sf.ser("@signature-params")}: {http_sf.ser([inner_list])}')
|
|
150
|
+
signature = self._key.sign("\n".join(base_lines).encode("utf-8"))
|
|
151
|
+
|
|
152
|
+
return http_sf.ser({SIG_LABEL: inner_list}), http_sf.ser({SIG_LABEL: signature})
|
|
153
|
+
|
|
154
|
+
def sign_request(self, uri: str) -> List[Tuple[bytes, bytes]]:
|
|
155
|
+
"Return the Web Bot Auth headers to add to a request for ``uri``."
|
|
156
|
+
agent_field = http_sf.ser(self.directory) # Signature-Agent value (an sf-string)
|
|
157
|
+
sig_input, sig = self._build_signature(
|
|
158
|
+
[("@authority", self.authority(uri)), ("signature-agent", agent_field)],
|
|
159
|
+
REQUEST_TAG,
|
|
160
|
+
self.validity,
|
|
161
|
+
)
|
|
162
|
+
return [
|
|
163
|
+
(b"Signature-Agent", agent_field.encode("ascii")),
|
|
164
|
+
(b"Signature-Input", sig_input.encode("ascii")),
|
|
165
|
+
(b"Signature", sig.encode("ascii")),
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
def sign_directory(self, authority: str) -> List[Tuple[bytes, bytes]]:
|
|
169
|
+
"Return the signature headers for a directory response served at ``authority``."
|
|
170
|
+
sig_input, sig = self._build_signature(
|
|
171
|
+
[("@authority", authority)], DIRECTORY_TAG, DIRECTORY_VALIDITY
|
|
172
|
+
)
|
|
173
|
+
return [
|
|
174
|
+
(b"Signature-Input", sig_input.encode("ascii")),
|
|
175
|
+
(b"Signature", sig.encode("ascii")),
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
def directory_json(self) -> bytes:
|
|
179
|
+
"The key directory body: a JWKS containing the public key."
|
|
180
|
+
key: Dict[str, object] = {
|
|
181
|
+
"kty": "OKP",
|
|
182
|
+
"crv": "Ed25519",
|
|
183
|
+
"x": self.public_x,
|
|
184
|
+
"kid": self.keyid,
|
|
185
|
+
"use": "sig",
|
|
186
|
+
}
|
|
187
|
+
if self.key_created is not None:
|
|
188
|
+
key["nbf"] = self.key_created
|
|
189
|
+
return json.dumps({"keys": [key]}, separators=(",", ":")).encode("utf-8")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
_SIGNER_CACHE: Dict[str, WebBotAuthSigner] = {}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _ui_uri_origin(config: SectionProxy) -> str:
|
|
196
|
+
"The scheme://host[:port] origin of ui_uri, or '' if it isn't a usable URL."
|
|
197
|
+
parts = urlsplit(config.get("ui_uri", "").strip())
|
|
198
|
+
if not parts.scheme or not parts.netloc:
|
|
199
|
+
return ""
|
|
200
|
+
return f"{parts.scheme}://{parts.netloc}"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def load_signer(config: SectionProxy) -> Optional[WebBotAuthSigner]:
|
|
204
|
+
"""
|
|
205
|
+
Build a ``WebBotAuthSigner`` from REDbot configuration, or return None if
|
|
206
|
+
Web Bot Auth is not configured. Raises ``WebBotAuthError`` on misconfiguration
|
|
207
|
+
so problems surface at startup rather than mid-request. Successful signers are
|
|
208
|
+
cached, keyed by key path, directory, validity, and the key file's mtime.
|
|
209
|
+
|
|
210
|
+
The key directory location is ``web_bot_auth_directory``; if unset, it
|
|
211
|
+
defaults to the origin of ``ui_uri`` (so enabling signing requires ``ui_uri``
|
|
212
|
+
to be set correctly to this instance's public origin).
|
|
213
|
+
"""
|
|
214
|
+
key_path = config.get("web_bot_auth_key", "").strip()
|
|
215
|
+
directory = config.get("web_bot_auth_directory", "").strip()
|
|
216
|
+
if not key_path:
|
|
217
|
+
if directory:
|
|
218
|
+
raise WebBotAuthError("web_bot_auth_directory is set but web_bot_auth_key is not.")
|
|
219
|
+
return None
|
|
220
|
+
if not directory:
|
|
221
|
+
directory = _ui_uri_origin(config)
|
|
222
|
+
if not directory:
|
|
223
|
+
raise WebBotAuthError(
|
|
224
|
+
"Web Bot Auth needs a key directory location: set web_bot_auth_directory, "
|
|
225
|
+
"or (for the daemon) set ui_uri to this instance's public origin."
|
|
226
|
+
)
|
|
227
|
+
validity = config.getint("web_bot_auth_validity", fallback=DEFAULT_VALIDITY)
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
mtime = os.path.getmtime(key_path)
|
|
231
|
+
except OSError as why:
|
|
232
|
+
raise WebBotAuthError(f"Can't read Web Bot Auth key '{key_path}': {why}") from why
|
|
233
|
+
|
|
234
|
+
cache_key = "\0".join([key_path, directory, str(validity), str(mtime)])
|
|
235
|
+
if cache_key not in _SIGNER_CACHE:
|
|
236
|
+
try:
|
|
237
|
+
with open(key_path, "rb") as fh:
|
|
238
|
+
key_pem = fh.read()
|
|
239
|
+
except OSError as why:
|
|
240
|
+
raise WebBotAuthError(f"Can't read Web Bot Auth key '{key_path}': {why}") from why
|
|
241
|
+
_SIGNER_CACHE[cache_key] = WebBotAuthSigner(
|
|
242
|
+
key_pem, directory, validity, key_created=int(mtime)
|
|
243
|
+
)
|
|
244
|
+
return _SIGNER_CACHE[cache_key]
|
|
@@ -43,7 +43,10 @@ def _validate_req_hdrs(raw: List[str]) -> Tuple[List[Tuple[str, str]], Optional[
|
|
|
43
43
|
if not name:
|
|
44
44
|
return [], "Request header field name is empty."
|
|
45
45
|
if any(c in _BAD_HEADER_NAME_CHARS or ord(c) < 33 or ord(c) > 126 for c in name):
|
|
46
|
-
return
|
|
46
|
+
return (
|
|
47
|
+
[],
|
|
48
|
+
f"Request header field name {name!r} contains invalid characters.",
|
|
49
|
+
)
|
|
47
50
|
if "\r" in value or "\n" in value:
|
|
48
51
|
return [], f"Request header {name!r} value contains CR or LF."
|
|
49
52
|
headers.append((name, value))
|
|
@@ -23,7 +23,10 @@ class RateLimiter:
|
|
|
23
23
|
self.loop = thor.loop
|
|
24
24
|
|
|
25
25
|
def process(
|
|
26
|
-
self,
|
|
26
|
+
self,
|
|
27
|
+
webui: RedWebUiProtocol,
|
|
28
|
+
test_uri: str,
|
|
29
|
+
error_response: Callable[..., None],
|
|
27
30
|
) -> None:
|
|
28
31
|
"""Enforce limits on webui."""
|
|
29
32
|
if not self.running:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: redbot
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.0
|
|
4
4
|
Summary: REDbot is lint for HTTP.
|
|
5
5
|
Author-email: Mark Nottingham <mnot@mnot.net>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -16,6 +16,8 @@ Classifier: Operating System :: MacOS :: MacOS X
|
|
|
16
16
|
Requires-Python: >=3.9
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
License-File: LICENSE.md
|
|
19
|
+
Requires-Dist: cryptography>=42.0.0
|
|
20
|
+
Requires-Dist: http-sf>=1.0
|
|
19
21
|
Requires-Dist: httplint>=2026.05.01
|
|
20
22
|
Requires-Dist: importlib_resources
|
|
21
23
|
Requires-Dist: Jinja2>=3.1.2
|
|
@@ -113,6 +115,46 @@ or
|
|
|
113
115
|
> podman run --rm -p 8000:8000 ghcr.io/mnot/redbot
|
|
114
116
|
|
|
115
117
|
|
|
118
|
+
## Web Bot Auth
|
|
119
|
+
|
|
120
|
+
REDbot can authenticate its outgoing requests using [Web Bot Auth](https://datatracker.ietf.org/doc/draft-meunier-web-bot-auth-architecture/), which signs requests with an Ed25519 key using [HTTP Message Signatures (RFC 9421)](https://www.rfc-editor.org/rfc/rfc9421). This lets origins verify that requests genuinely come from your REDbot instance. The implementation follows the IETF drafts and is not specific to any one verifier.
|
|
121
|
+
|
|
122
|
+
Requests are sent unsigned by default. REDbot only attaches a signature when the origin challenges for one -- that is, when it returns a `401`, `403`, or `429` response carrying an `Accept-Signature` header -- and then transparently retries the request signed. This avoids advertising the bot's identity to servers that don't ask for it.
|
|
123
|
+
|
|
124
|
+
### Generating a key
|
|
125
|
+
|
|
126
|
+
Create an Ed25519 private key in PEM form:
|
|
127
|
+
|
|
128
|
+
~~~ bash
|
|
129
|
+
> openssl genpkey -algorithm ed25519 -out web-bot-auth-key.pem
|
|
130
|
+
~~~
|
|
131
|
+
|
|
132
|
+
Keep this file private and readable by the REDbot process. The matching public key is published automatically (see below); you do not need to extract it yourself.
|
|
133
|
+
|
|
134
|
+
### Configuring
|
|
135
|
+
|
|
136
|
+
Set these in `config.txt` (for `redbot_daemon`):
|
|
137
|
+
|
|
138
|
+
~~~ ini
|
|
139
|
+
web_bot_auth_key = /path/to/web-bot-auth-key.pem
|
|
140
|
+
web_bot_auth_directory = https://your-redbot.example
|
|
141
|
+
~~~
|
|
142
|
+
|
|
143
|
+
`web_bot_auth_directory` is the HTTPS origin where your public key directory is hosted. When you run `redbot_daemon`, it serves that directory at `/.well-known/http-message-signatures-directory` (a JWKS, self-signed per the spec), so a verifier can fetch your public key. Make sure that path is reachable at the origin you configured.
|
|
144
|
+
|
|
145
|
+
`web_bot_auth_directory` is optional: if you omit it, REDbot uses the origin of `ui_uri`. Because `ui_uri` defaults to `https://redbot.org/`, **enabling signing requires `ui_uri` to be set to your instance's real public origin** — otherwise you would publish the wrong bot identity. Set `web_bot_auth_directory` explicitly if your key directory lives on a different origin than the UI.
|
|
146
|
+
|
|
147
|
+
For the command-line tool, pass the equivalent flags (there is no `ui_uri`, so the directory is required):
|
|
148
|
+
|
|
149
|
+
~~~ bash
|
|
150
|
+
> redbot --web-bot-auth-key web-bot-auth-key.pem --web-bot-auth-directory https://your-redbot.example https://example.com/
|
|
151
|
+
~~~
|
|
152
|
+
|
|
153
|
+
### Registering with a verifier
|
|
154
|
+
|
|
155
|
+
To be recognised by a specific verifier, register your directory URL with them according to their process.
|
|
156
|
+
|
|
157
|
+
|
|
116
158
|
## Credits
|
|
117
159
|
|
|
118
160
|
Icons by [Font Awesome](https://fontawesome.com/). REDbot includes code from [tippy.js](https://atomiks.github.io/tippyjs/) and [prettify.js](https://github.com/google/code-prettify).
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|