Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +108 −0
| @@ -2321,6 +2321,23 @@ dependencies = [ | |||
| 2321 | 2321 | "pin-project-lite", | |
| 2322 | 2322 | "smallvec", | |
| 2323 | 2323 | "tokio", | |
| 2324 | + | "want", | |
| 2325 | + | ] | |
| 2326 | + | ||
| 2327 | + | [[package]] | |
| 2328 | + | name = "hyper-tls" | |
| 2329 | + | version = "0.6.0" | |
| 2330 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2331 | + | checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" | |
| 2332 | + | dependencies = [ | |
| 2333 | + | "bytes", | |
| 2334 | + | "http-body-util", | |
| 2335 | + | "hyper", | |
| 2336 | + | "hyper-util", | |
| 2337 | + | "native-tls", | |
| 2338 | + | "tokio", | |
| 2339 | + | "tokio-native-tls", | |
| 2340 | + | "tower-service", | |
| 2324 | 2341 | ] | |
| 2325 | 2342 | ||
| 2326 | 2343 | [[package]] | |
| @@ -2329,13 +2346,21 @@ version = "0.1.20" | |||
| 2329 | 2346 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2330 | 2347 | checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" | |
| 2331 | 2348 | dependencies = [ | |
| 2349 | + | "base64", | |
| 2332 | 2350 | "bytes", | |
| 2351 | + | "futures-channel", | |
| 2352 | + | "futures-util", | |
| 2333 | 2353 | "http", | |
| 2334 | 2354 | "http-body", | |
| 2335 | 2355 | "hyper", | |
| 2356 | + | "ipnet", | |
| 2357 | + | "libc", | |
| 2358 | + | "percent-encoding", | |
| 2336 | 2359 | "pin-project-lite", | |
| 2360 | + | "socket2", | |
| 2337 | 2361 | "tokio", | |
| 2338 | 2362 | "tower-service", | |
| 2363 | + | "tracing", | |
| 2339 | 2364 | ] | |
| 2340 | 2365 | ||
| 2341 | 2366 | [[package]] | |
| @@ -2556,6 +2581,12 @@ dependencies = [ | |||
| 2556 | 2581 | ] | |
| 2557 | 2582 | ||
| 2558 | 2583 | [[package]] | |
| 2584 | + | name = "ipnet" | |
| 2585 | + | version = "2.12.0" | |
| 2586 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2587 | + | checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" | |
| 2588 | + | ||
| 2589 | + | [[package]] | |
| 2559 | 2590 | name = "is_terminal_polyfill" | |
| 2560 | 2591 | version = "1.70.2" | |
| 2561 | 2592 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3850,6 +3881,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3850 | 3881 | checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884" | |
| 3851 | 3882 | ||
| 3852 | 3883 | [[package]] | |
| 3884 | + | name = "reqwest" | |
| 3885 | + | version = "0.12.28" | |
| 3886 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3887 | + | checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" | |
| 3888 | + | dependencies = [ | |
| 3889 | + | "base64", | |
| 3890 | + | "bytes", | |
| 3891 | + | "futures-core", | |
| 3892 | + | "http", | |
| 3893 | + | "http-body", | |
| 3894 | + | "http-body-util", | |
| 3895 | + | "hyper", | |
| 3896 | + | "hyper-tls", | |
| 3897 | + | "hyper-util", | |
| 3898 | + | "js-sys", | |
| 3899 | + | "log", | |
| 3900 | + | "native-tls", | |
| 3901 | + | "percent-encoding", | |
| 3902 | + | "pin-project-lite", | |
| 3903 | + | "rustls-pki-types", | |
| 3904 | + | "serde", | |
| 3905 | + | "serde_json", | |
| 3906 | + | "serde_urlencoded", | |
| 3907 | + | "sync_wrapper", | |
| 3908 | + | "tokio", | |
| 3909 | + | "tokio-native-tls", | |
| 3910 | + | "tower", | |
| 3911 | + | "tower-http", | |
| 3912 | + | "tower-service", | |
| 3913 | + | "url", | |
| 3914 | + | "wasm-bindgen", | |
| 3915 | + | "wasm-bindgen-futures", | |
| 3916 | + | "web-sys", | |
| 3917 | + | ] | |
| 3918 | + | ||
| 3919 | + | [[package]] | |
| 3853 | 3920 | name = "rfc6979" | |
| 3854 | 3921 | version = "0.4.0" | |
| 3855 | 3922 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -4099,6 +4166,15 @@ dependencies = [ | |||
| 4099 | 4166 | ] | |
| 4100 | 4167 | ||
| 4101 | 4168 | [[package]] | |
| 4169 | + | name = "rustls-pki-types" | |
| 4170 | + | version = "1.15.1" | |
| 4171 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4172 | + | checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" | |
| 4173 | + | dependencies = [ | |
| 4174 | + | "zeroize", | |
| 4175 | + | ] | |
| 4176 | + | ||
| 4177 | + | [[package]] | |
| 4102 | 4178 | name = "rustversion" | |
| 4103 | 4179 | version = "1.0.23" | |
| 4104 | 4180 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -4931,6 +5007,9 @@ name = "sync_wrapper" | |||
| 4931 | 5007 | version = "1.0.2" | |
| 4932 | 5008 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4933 | 5009 | checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" | |
| 5010 | + | dependencies = [ | |
| 5011 | + | "futures-core", | |
| 5012 | + | ] | |
| 4934 | 5013 | ||
| 4935 | 5014 | [[package]] | |
| 4936 | 5015 | name = "synstructure" | |
| @@ -5219,14 +5298,17 @@ dependencies = [ | |||
| 5219 | 5298 | "bitflags", | |
| 5220 | 5299 | "bytes", | |
| 5221 | 5300 | "futures-core", | |
| 5301 | + | "futures-util", | |
| 5222 | 5302 | "http", | |
| 5223 | 5303 | "http-body", | |
| 5224 | 5304 | "pin-project-lite", | |
| 5225 | 5305 | "tokio", | |
| 5226 | 5306 | "tokio-util", | |
| 5307 | + | "tower", | |
| 5227 | 5308 | "tower-layer", | |
| 5228 | 5309 | "tower-service", | |
| 5229 | 5310 | "tracing", | |
| 5311 | + | "url", | |
| 5230 | 5312 | "uuid", | |
| 5231 | 5313 | ] | |
| 5232 | 5314 | ||
| @@ -5348,6 +5430,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 5348 | 5430 | checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" | |
| 5349 | 5431 | ||
| 5350 | 5432 | [[package]] | |
| 5433 | + | name = "try-lock" | |
| 5434 | + | version = "0.2.5" | |
| 5435 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5436 | + | checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" | |
| 5437 | + | ||
| 5438 | + | [[package]] | |
| 5351 | 5439 | name = "twofish" | |
| 5352 | 5440 | version = "0.7.1" | |
| 5353 | 5441 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -5528,6 +5616,15 @@ dependencies = [ | |||
| 5528 | 5616 | ] | |
| 5529 | 5617 | ||
| 5530 | 5618 | [[package]] | |
| 5619 | + | name = "want" | |
| 5620 | + | version = "0.3.1" | |
| 5621 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5622 | + | checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" | |
| 5623 | + | dependencies = [ | |
| 5624 | + | "try-lock", | |
| 5625 | + | ] | |
| 5626 | + | ||
| 5627 | + | [[package]] | |
| 5531 | 5628 | name = "wasi" | |
| 5532 | 5629 | version = "0.11.1+wasi-snapshot-preview1" | |
| 5533 | 5630 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -5618,6 +5715,7 @@ dependencies = [ | |||
| 5618 | 5715 | "maud", | |
| 5619 | 5716 | "mime_guess", | |
| 5620 | 5717 | "model", | |
| 5718 | + | "reqwest", | |
| 5621 | 5719 | "rust-embed", | |
| 5622 | 5720 | "serde", | |
| 5623 | 5721 | "serde_json", | |
| @@ -5635,6 +5733,16 @@ dependencies = [ | |||
| 5635 | 5733 | ] | |
| 5636 | 5734 | ||
| 5637 | 5735 | [[package]] | |
| 5736 | + | name = "web-sys" | |
| 5737 | + | version = "0.3.103" | |
| 5738 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5739 | + | checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" | |
| 5740 | + | dependencies = [ | |
| 5741 | + | "js-sys", | |
| 5742 | + | "wasm-bindgen", | |
| 5743 | + | ] | |
| 5744 | + | ||
| 5745 | + | [[package]] | |
| 5638 | 5746 | name = "web-time" | |
| 5639 | 5747 | version = "1.1.0" | |
| 5640 | 5748 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
Cargo.toml +7 −0
| @@ -160,6 +160,13 @@ tokio-util = { version = "0.7", features = ["io"] } | |||
| 160 | 160 | flate2 = "1" | |
| 161 | 161 | # Content search (ripgrep's engine). Dual Unlicense/MIT — MIT satisfies the gate. | |
| 162 | 162 | grep = "0.3" | |
| 163 | + | # Outbound HTTP for optional captcha verification only. native-tls (system | |
| 164 | + | # OpenSSL, already a build input) not rustls, so `ring` stays out of the graph; | |
| 165 | + | # http2/charset off to keep the tree small. See docs/decisions.md. | |
| 166 | + | reqwest = { version = "0.12", default-features = false, features = [ | |
| 167 | + | "native-tls", | |
| 168 | + | "json", | |
| 169 | + | ] } | |
| 163 | 170 | # SSH server (default features: aws-lc-rs backend, no ring; re-exports ssh-key). | |
| 164 | 171 | russh = "0.62" | |
| 165 | 172 | tracing = "0.1" | |
assets/base.css +17 −0
| @@ -592,6 +592,23 @@ select:focus { | |||
| 592 | 592 | .token-list { | |
| 593 | 593 | margin-bottom: 1rem; | |
| 594 | 594 | } | |
| 595 | + | .key-list { | |
| 596 | + | margin-bottom: 1rem; | |
| 597 | + | } | |
| 598 | + | .key-add textarea { | |
| 599 | + | font-family: var(--fb-font-mono); | |
| 600 | + | } | |
| 601 | + | .key-add-row { | |
| 602 | + | display: flex; | |
| 603 | + | align-items: center; | |
| 604 | + | gap: 0.75rem; | |
| 605 | + | flex-wrap: wrap; | |
| 606 | + | margin-bottom: 0.5rem; | |
| 607 | + | } | |
| 608 | + | .key-add-row select, | |
| 609 | + | .key-add-row input { | |
| 610 | + | width: auto; | |
| 611 | + | } | |
| 595 | 612 | .token-scopes { | |
| 596 | 613 | margin: 0.75rem 0 0; | |
| 597 | 614 | padding: 0.5rem 0.85rem; | |
crates/config/src/lib.rs +2 −2
| @@ -32,8 +32,8 @@ use figment::providers::{Env, Format, Serialized, Toml}; | |||
| 32 | 32 | ||
| 33 | 33 | pub use crate::secret::{REDACTED, Secret}; | |
| 34 | 34 | pub use crate::types::{ | |
| 35 | - | Auth, Config, Database, DefaultVisibility, Git, Instance, Log, LogFormat, LogLevel, Mail, | |
| 36 | - | MailBackend, MailEncryption, Search, Server, Ssh, Storage, Ui, | |
| 35 | + | Auth, Captcha, CaptchaProvider, Config, Database, DefaultVisibility, Git, Instance, Log, | |
| 36 | + | LogFormat, LogLevel, Mail, MailBackend, MailEncryption, Search, Server, Ssh, Storage, Ui, | |
| 37 | 37 | }; | |
| 38 | 38 | ||
| 39 | 39 | /// System-wide configuration path, the lowest-priority file source. | |
crates/config/src/types.rs +85 −0
| @@ -32,6 +32,9 @@ pub struct Config { | |||
| 32 | 32 | pub auth: Auth, | |
| 33 | 33 | /// Outbound mail. | |
| 34 | 34 | pub mail: Mail, | |
| 35 | + | /// Optional bot mitigation on self-registration. | |
| 36 | + | #[serde(default)] | |
| 37 | + | pub captcha: Captcha, | |
| 35 | 38 | /// Web UI presentation. | |
| 36 | 39 | pub ui: Ui, | |
| 37 | 40 | /// Git binary and pack-transport limits. | |
| @@ -126,6 +129,88 @@ impl Default for Instance { | |||
| 126 | 129 | } | |
| 127 | 130 | } | |
| 128 | 131 | ||
| 132 | + | /// A supported captcha provider for the sign-up form. | |
| 133 | + | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 134 | + | #[serde(rename_all = "lowercase")] | |
| 135 | + | pub enum CaptchaProvider { | |
| 136 | + | /// No captcha (the default). | |
| 137 | + | #[default] | |
| 138 | + | None, | |
| 139 | + | /// [hCaptcha](https://www.hcaptcha.com). | |
| 140 | + | HCaptcha, | |
| 141 | + | /// Google [reCAPTCHA](https://developers.google.com/recaptcha) v2. | |
| 142 | + | ReCaptcha, | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | impl CaptchaProvider { | |
| 146 | + | /// The browser widget script URL, or `None` for [`CaptchaProvider::None`]. | |
| 147 | + | #[must_use] | |
| 148 | + | pub fn script_url(self) -> Option<&'static str> { | |
| 149 | + | match self { | |
| 150 | + | Self::None => None, | |
| 151 | + | Self::HCaptcha => Some("https://js.hcaptcha.com/1/api.js"), | |
| 152 | + | Self::ReCaptcha => Some("https://www.google.com/recaptcha/api.js"), | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | /// The CSS class the widget script looks for to render the challenge. | |
| 157 | + | #[must_use] | |
| 158 | + | pub fn widget_class(self) -> &'static str { | |
| 159 | + | match self { | |
| 160 | + | Self::None | Self::HCaptcha => "h-captcha", | |
| 161 | + | Self::ReCaptcha => "g-recaptcha", | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | /// The form field the widget populates with the solved token. | |
| 166 | + | #[must_use] | |
| 167 | + | pub fn response_field(self) -> &'static str { | |
| 168 | + | match self { | |
| 169 | + | Self::None | Self::HCaptcha => "h-captcha-response", | |
| 170 | + | Self::ReCaptcha => "g-recaptcha-response", | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /// The server-side verification endpoint. | |
| 175 | + | #[must_use] | |
| 176 | + | pub fn verify_url(self) -> &'static str { | |
| 177 | + | match self { | |
| 178 | + | Self::None | Self::HCaptcha => "https://api.hcaptcha.com/siteverify", | |
| 179 | + | Self::ReCaptcha => "https://www.google.com/recaptcha/api/siteverify", | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | /// `[captcha]` — optional bot mitigation on the sign-up form. | |
| 185 | + | /// | |
| 186 | + | /// Off by default. Enabling it is a deliberate, opt-in exception to the "no | |
| 187 | + | /// third-party network requests from any page" rule (§9.5): the chosen provider's | |
| 188 | + | /// widget script loads on `/register` and the server verifies the solved token | |
| 189 | + | /// out-of-band. See `docs/decisions.md`. | |
| 190 | + | #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 191 | + | #[serde(deny_unknown_fields)] | |
| 192 | + | pub struct Captcha { | |
| 193 | + | /// Which provider to use (or `none`, the default). | |
| 194 | + | #[serde(default)] | |
| 195 | + | pub provider: CaptchaProvider, | |
| 196 | + | /// The public site key embedded in the page. | |
| 197 | + | #[serde(default)] | |
| 198 | + | pub site_key: String, | |
| 199 | + | /// The provider secret used for server-side verification. | |
| 200 | + | #[serde(default)] | |
| 201 | + | pub secret_key: Option<Secret>, | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | impl Captcha { | |
| 205 | + | /// Whether captcha is fully configured and should be enforced on sign-up. | |
| 206 | + | #[must_use] | |
| 207 | + | pub fn enabled(&self) -> bool { | |
| 208 | + | self.provider != CaptchaProvider::None | |
| 209 | + | && !self.site_key.is_empty() | |
| 210 | + | && self.secret_key.is_some() | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 129 | 214 | /// `[server]` — HTTP binding and limits. | |
| 130 | 215 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 131 | 216 | #[serde(deny_unknown_fields)] | |
crates/web/Cargo.toml +1 −0
| @@ -30,6 +30,7 @@ ammonia = { workspace = true } | |||
| 30 | 30 | tokio-util = { workspace = true } | |
| 31 | 31 | flate2 = { workspace = true } | |
| 32 | 32 | grep = { workspace = true } | |
| 33 | + | reqwest = { workspace = true } | |
| 33 | 34 | base64 = { workspace = true } | |
| 34 | 35 | time = { workspace = true } | |
| 35 | 36 | serde = { workspace = true } | |
crates/web/src/lib.rs +2 −0
| @@ -157,6 +157,8 @@ pub fn build_router(state: AppState) -> Router { | |||
| 157 | 157 | .route("/settings/password", post(pages::account_password)) | |
| 158 | 158 | .route("/settings/tokens", post(pages::token_create)) | |
| 159 | 159 | .route("/settings/tokens/revoke", post(pages::token_revoke)) | |
| 160 | + | .route("/settings/keys", post(pages::key_add)) | |
| 161 | + | .route("/settings/keys/delete", post(pages::key_delete)) | |
| 160 | 162 | .route("/settings/avatar", post(avatar::upload)) | |
| 161 | 163 | .route("/settings/theme", get(pages::set_theme)) | |
| 162 | 164 | .route("/avatar/{username}", get(avatar::show)) | |
crates/web/src/pages.rs +257 −7
| @@ -12,7 +12,7 @@ use axum::extract::{Path, State}; | |||
| 12 | 12 | use axum::http::{HeaderMap, Uri, header}; | |
| 13 | 13 | use axum::response::{IntoResponse, Redirect, Response}; | |
| 14 | 14 | use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; | |
| 15 | - | use maud::{Markup, html}; | |
| 15 | + | use maud::{Markup, PreEscaped, html}; | |
| 16 | 16 | use model::User; | |
| 17 | 17 | use serde::Deserialize; | |
| 18 | 18 | ||
| @@ -559,11 +559,25 @@ pub struct RegisterForm { | |||
| 559 | 559 | /// Confirmation. | |
| 560 | 560 | #[serde(default)] | |
| 561 | 561 | confirm: String, | |
| 562 | + | /// hCaptcha solved token (populated by the hCaptcha widget). | |
| 563 | + | #[serde(default, rename = "h-captcha-response")] | |
| 564 | + | hcaptcha_response: String, | |
| 565 | + | /// reCAPTCHA solved token (populated by the reCAPTCHA widget). | |
| 566 | + | #[serde(default, rename = "g-recaptcha-response")] | |
| 567 | + | grecaptcha_response: String, | |
| 562 | 568 | /// CSRF token. | |
| 563 | 569 | #[serde(rename = "_csrf")] | |
| 564 | 570 | csrf: String, | |
| 565 | 571 | } | |
| 566 | 572 | ||
| 573 | + | /// The captcha provider and public site key when captcha is enabled, for | |
| 574 | + | /// rendering the widget on the sign-up form. | |
| 575 | + | fn captcha_view(cfg: &config::Config) -> Option<(config::CaptchaProvider, String)> { | |
| 576 | + | cfg.captcha | |
| 577 | + | .enabled() | |
| 578 | + | .then(|| (cfg.captcha.provider, cfg.captcha.site_key.clone())) | |
| 579 | + | } | |
| 580 | + | ||
| 567 | 581 | /// `GET /register` — the sign-up form, or `404` when registration is disabled. | |
| 568 | 582 | pub async fn register_form( | |
| 569 | 583 | State(state): State<AppState>, | |
| @@ -578,12 +592,25 @@ pub async fn register_form( | |||
| 578 | 592 | return Redirect::to("/").into_response(); | |
| 579 | 593 | } | |
| 580 | 594 | let (jar, chrome) = build_chrome(&state, jar, None, uri.path().to_string()); | |
| 581 | - | let body = register_body(&chrome.csrf, None, "", ""); | |
| 595 | + | let body = register_body( | |
| 596 | + | &chrome.csrf, | |
| 597 | + | None, | |
| 598 | + | "", | |
| 599 | + | "", | |
| 600 | + | captcha_view(&state.config).as_ref(), | |
| 601 | + | ); | |
| 582 | 602 | (jar, page(&chrome, "Sign up", body)).into_response() | |
| 583 | 603 | } | |
| 584 | 604 | ||
| 585 | - | /// Render the sign-up form with an optional error and prefilled fields. | |
| 586 | - | fn register_body(csrf: &str, error: Option<&str>, username: &str, email: &str) -> Markup { | |
| 605 | + | /// Render the sign-up form with an optional error, prefilled fields, and the | |
| 606 | + | /// captcha widget when one is configured. | |
| 607 | + | fn register_body( | |
| 608 | + | csrf: &str, | |
| 609 | + | error: Option<&str>, | |
| 610 | + | username: &str, | |
| 611 | + | email: &str, | |
| 612 | + | captcha: Option<&(config::CaptchaProvider, String)>, | |
| 613 | + | ) -> Markup { | |
| 587 | 614 | html! { | |
| 588 | 615 | div class="auth-wrap" { | |
| 589 | 616 | div class="auth-card" { | |
| @@ -605,6 +632,12 @@ fn register_body(csrf: &str, error: Option<&str>, username: &str, email: &str) - | |||
| 605 | 632 | label for="confirm" { "Confirm password" } | |
| 606 | 633 | input type="password" id="confirm" name="confirm" | |
| 607 | 634 | autocomplete="new-password" required; | |
| 635 | + | @if let Some((provider, site_key)) = captcha { | |
| 636 | + | @if let Some(script) = provider.script_url() { | |
| 637 | + | (PreEscaped(format!("<script src=\"{script}\" async defer></script>"))) | |
| 638 | + | div class=(provider.widget_class()) data-sitekey=(site_key) {} | |
| 639 | + | } | |
| 640 | + | } | |
| 608 | 641 | button class="btn btn-primary" type="submit" { "Create account" } | |
| 609 | 642 | } | |
| 610 | 643 | p class="muted auth-alt" { "Already have an account? " a href="/login" { "Sign in" } } | |
| @@ -629,9 +662,10 @@ pub async fn register_submit( | |||
| 629 | 662 | let username = form.username.trim().to_string(); | |
| 630 | 663 | let email = form.email.trim().to_string(); | |
| 631 | 664 | ||
| 665 | + | let captcha = captcha_view(&state.config); | |
| 632 | 666 | let reject = |msg: &str, username: &str, email: &str| { | |
| 633 | 667 | let (jar, chrome) = build_chrome(&state, jar.clone(), None, "/register".to_string()); | |
| 634 | - | let body = register_body(&chrome.csrf, Some(msg), username, email); | |
| 668 | + | let body = register_body(&chrome.csrf, Some(msg), username, email, captcha.as_ref()); | |
| 635 | 669 | ( | |
| 636 | 670 | axum::http::StatusCode::BAD_REQUEST, | |
| 637 | 671 | jar, | |
| @@ -650,6 +684,22 @@ pub async fn register_submit( | |||
| 650 | 684 | return reject("Password must be at least 8 characters.", &username, &email); | |
| 651 | 685 | } | |
| 652 | 686 | ||
| 687 | + | // When captcha is enabled, the solved token must verify with the provider | |
| 688 | + | // before we touch the store. | |
| 689 | + | if state.config.captcha.enabled() { | |
| 690 | + | let token = match state.config.captcha.provider { | |
| 691 | + | config::CaptchaProvider::ReCaptcha => form.grecaptcha_response.trim(), | |
| 692 | + | _ => form.hcaptcha_response.trim(), | |
| 693 | + | }; | |
| 694 | + | if !verify_captcha(&state.config.captcha, token).await { | |
| 695 | + | return reject( | |
| 696 | + | "Captcha verification failed. Please try again.", | |
| 697 | + | &username, | |
| 698 | + | &email, | |
| 699 | + | ); | |
| 700 | + | } | |
| 701 | + | } | |
| 702 | + | ||
| 653 | 703 | let params = auth::HashParams { | |
| 654 | 704 | m_cost: state.config.auth.argon2_m_cost, | |
| 655 | 705 | t_cost: state.config.auth.argon2_t_cost, | |
| @@ -696,6 +746,42 @@ pub async fn register_submit( | |||
| 696 | 746 | } | |
| 697 | 747 | } | |
| 698 | 748 | ||
| 749 | + | /// The relevant fields of a provider `siteverify` response. | |
| 750 | + | #[derive(Debug, Deserialize)] | |
| 751 | + | struct SiteVerify { | |
| 752 | + | /// Whether the token was valid. | |
| 753 | + | #[serde(default)] | |
| 754 | + | success: bool, | |
| 755 | + | } | |
| 756 | + | ||
| 757 | + | /// Verify a solved captcha `token` with the configured provider. Returns `false` | |
| 758 | + | /// on an empty token, a missing secret, a network error, or a rejected token — a | |
| 759 | + | /// closed failure mode, so a broken verifier never lets a bot through. | |
| 760 | + | async fn verify_captcha(cfg: &config::Captcha, token: &str) -> bool { | |
| 761 | + | if token.is_empty() { | |
| 762 | + | return false; | |
| 763 | + | } | |
| 764 | + | let Some(secret) = cfg.secret_key.as_ref() else { | |
| 765 | + | return false; | |
| 766 | + | }; | |
| 767 | + | let Ok(client) = reqwest::Client::builder() | |
| 768 | + | .timeout(std::time::Duration::from_secs(10)) | |
| 769 | + | .build() | |
| 770 | + | else { | |
| 771 | + | return false; | |
| 772 | + | }; | |
| 773 | + | let form = [("secret", secret.expose()), ("response", token)]; | |
| 774 | + | match client | |
| 775 | + | .post(cfg.provider.verify_url()) | |
| 776 | + | .form(&form) | |
| 777 | + | .send() | |
| 778 | + | .await | |
| 779 | + | { | |
| 780 | + | Ok(resp) => resp.json::<SiteVerify>().await.is_ok_and(|v| v.success), | |
| 781 | + | Err(_) => false, | |
| 782 | + | } | |
| 783 | + | } | |
| 784 | + | ||
| 699 | 785 | /// Logout form field. | |
| 700 | 786 | #[derive(Debug, Deserialize)] | |
| 701 | 787 | pub struct CsrfForm { | |
| @@ -841,8 +927,9 @@ pub async fn settings( | |||
| 841 | 927 | uri: Uri, | |
| 842 | 928 | ) -> AppResult<Response> { | |
| 843 | 929 | let tokens = state.store.tokens_by_user(&user.id).await?; | |
| 930 | + | let keys = state.store.keys_by_user(&user.id).await?; | |
| 844 | 931 | let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string()); | |
| 845 | - | let body = settings_body(&user, &chrome.csrf, &tokens, None); | |
| 932 | + | let body = settings_body(&user, &chrome.csrf, &tokens, &keys, None); | |
| 846 | 933 | Ok((jar, page(&chrome, "Settings", body)).into_response()) | |
| 847 | 934 | } | |
| 848 | 935 | ||
| @@ -1129,9 +1216,10 @@ pub async fn token_create( | |||
| 1129 | 1216 | .tokens_by_user(&user.id) | |
| 1130 | 1217 | .await | |
| 1131 | 1218 | .unwrap_or_default(); | |
| 1219 | + | let keys = state.store.keys_by_user(&user.id).await.unwrap_or_default(); | |
| 1132 | 1220 | let (jar, chrome) = | |
| 1133 | 1221 | build_chrome(&state, jar, Some(user.clone()), "/settings".to_string()); | |
| 1134 | - | let body = settings_body(&user, &chrome.csrf, &tokens, Some(&jwt)); | |
| 1222 | + | let body = settings_body(&user, &chrome.csrf, &tokens, &keys, Some(&jwt)); | |
| 1135 | 1223 | (jar, page(&chrome, "Settings", body)).into_response() | |
| 1136 | 1224 | } | |
| 1137 | 1225 | Err(err) => err.into_response(), | |
| @@ -1174,11 +1262,118 @@ pub async fn token_revoke( | |||
| 1174 | 1262 | Redirect::to("/settings").into_response() | |
| 1175 | 1263 | } | |
| 1176 | 1264 | ||
| 1265 | + | /// The add-key form. | |
| 1266 | + | #[derive(Debug, Deserialize)] | |
| 1267 | + | pub struct KeyAddForm { | |
| 1268 | + | /// `ssh` or `gpg`. | |
| 1269 | + | #[serde(default)] | |
| 1270 | + | kind: String, | |
| 1271 | + | /// Optional label. | |
| 1272 | + | #[serde(default)] | |
| 1273 | + | name: String, | |
| 1274 | + | /// The public key material (`authorized_keys` line or armored block). | |
| 1275 | + | #[serde(default)] | |
| 1276 | + | key: String, | |
| 1277 | + | /// CSRF token. | |
| 1278 | + | #[serde(rename = "_csrf")] | |
| 1279 | + | csrf: String, | |
| 1280 | + | } | |
| 1281 | + | ||
| 1282 | + | /// `POST /settings/keys` — register an SSH or GPG public key for the viewer. | |
| 1283 | + | pub async fn key_add( | |
| 1284 | + | State(state): State<AppState>, | |
| 1285 | + | RequireUser(user): RequireUser, | |
| 1286 | + | jar: CookieJar, | |
| 1287 | + | headers: HeaderMap, | |
| 1288 | + | Form(form): Form<KeyAddForm>, | |
| 1289 | + | ) -> Response { | |
| 1290 | + | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | |
| 1291 | + | return AppError::Forbidden.into_response(); | |
| 1292 | + | } | |
| 1293 | + | let Some(kind) = model::KeyKind::from_token(&form.kind) else { | |
| 1294 | + | return AppError::BadRequest("Choose a key type.".to_string()).into_response(); | |
| 1295 | + | }; | |
| 1296 | + | let material = form.key.trim(); | |
| 1297 | + | let Ok(parsed) = git::parse_public_key(kind, material) else { | |
| 1298 | + | return AppError::BadRequest(format!("That does not look like a valid {kind} key.")) | |
| 1299 | + | .into_response(); | |
| 1300 | + | }; | |
| 1301 | + | // Reject a duplicate fingerprint (registered to anyone). | |
| 1302 | + | match state | |
| 1303 | + | .store | |
| 1304 | + | .key_by_fingerprint(kind, &parsed.fingerprint) | |
| 1305 | + | .await | |
| 1306 | + | { | |
| 1307 | + | Ok(Some(_)) => { | |
| 1308 | + | return AppError::BadRequest("That key is already registered.".to_string()) | |
| 1309 | + | .into_response(); | |
| 1310 | + | } | |
| 1311 | + | Ok(None) => {} | |
| 1312 | + | Err(err) => return AppError::from(err).into_response(), | |
| 1313 | + | } | |
| 1314 | + | let name = form.name.trim(); | |
| 1315 | + | let label = (!name.is_empty()) | |
| 1316 | + | .then(|| name.to_string()) | |
| 1317 | + | .or(parsed.label); | |
| 1318 | + | match state | |
| 1319 | + | .store | |
| 1320 | + | .create_key(store::NewKey { | |
| 1321 | + | user_id: user.id.clone(), | |
| 1322 | + | kind, | |
| 1323 | + | name: label, | |
| 1324 | + | fingerprint: parsed.fingerprint, | |
| 1325 | + | public_key: parsed.public_key, | |
| 1326 | + | }) | |
| 1327 | + | .await | |
| 1328 | + | { | |
| 1329 | + | Ok(_) => Redirect::to("/settings").into_response(), | |
| 1330 | + | Err(store::StoreError::Conflict { .. }) => { | |
| 1331 | + | AppError::BadRequest("That key is already registered.".to_string()).into_response() | |
| 1332 | + | } | |
| 1333 | + | Err(err) => AppError::from(err).into_response(), | |
| 1334 | + | } | |
| 1335 | + | } | |
| 1336 | + | ||
| 1337 | + | /// The delete-key form. | |
| 1338 | + | #[derive(Debug, Deserialize)] | |
| 1339 | + | pub struct KeyDeleteForm { | |
| 1340 | + | /// The key id. | |
| 1341 | + | #[serde(default)] | |
| 1342 | + | id: String, | |
| 1343 | + | /// CSRF token. | |
| 1344 | + | #[serde(rename = "_csrf")] | |
| 1345 | + | csrf: String, | |
| 1346 | + | } | |
| 1347 | + | ||
| 1348 | + | /// `POST /settings/keys/delete` — delete one of the viewer's keys. | |
| 1349 | + | pub async fn key_delete( | |
| 1350 | + | State(state): State<AppState>, | |
| 1351 | + | RequireUser(user): RequireUser, | |
| 1352 | + | jar: CookieJar, | |
| 1353 | + | headers: HeaderMap, | |
| 1354 | + | Form(form): Form<KeyDeleteForm>, | |
| 1355 | + | ) -> Response { | |
| 1356 | + | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | |
| 1357 | + | return AppError::Forbidden.into_response(); | |
| 1358 | + | } | |
| 1359 | + | // Only delete a key that belongs to the viewer. | |
| 1360 | + | let owned = state | |
| 1361 | + | .store | |
| 1362 | + | .keys_by_user(&user.id) | |
| 1363 | + | .await | |
| 1364 | + | .is_ok_and(|keys| keys.iter().any(|k| k.id == form.id)); | |
| 1365 | + | if owned { | |
| 1366 | + | let _ = state.store.delete_key(&form.id).await; | |
| 1367 | + | } | |
| 1368 | + | Redirect::to("/settings").into_response() | |
| 1369 | + | } | |
| 1370 | + | ||
| 1177 | 1371 | /// Render the settings page: avatar, profile, account credentials, and tokens. | |
| 1178 | 1372 | fn settings_body( | |
| 1179 | 1373 | user: &User, | |
| 1180 | 1374 | csrf: &str, | |
| 1181 | 1375 | tokens: &[model::ApiToken], | |
| 1376 | + | keys: &[model::Key], | |
| 1182 | 1377 | new_token: Option<&str>, | |
| 1183 | 1378 | ) -> Markup { | |
| 1184 | 1379 | let val = |v: &Option<String>| v.clone().unwrap_or_default(); | |
| @@ -1265,10 +1460,65 @@ fn settings_body( | |||
| 1265 | 1460 | } | |
| 1266 | 1461 | } | |
| 1267 | 1462 | } | |
| 1463 | + | (keys_section(csrf, keys)) | |
| 1268 | 1464 | (tokens_section(csrf, tokens, new_token)) | |
| 1269 | 1465 | } | |
| 1270 | 1466 | } | |
| 1271 | 1467 | ||
| 1468 | + | /// The SSH and GPG keys settings section. | |
| 1469 | + | fn keys_section(csrf: &str, keys: &[model::Key]) -> Markup { | |
| 1470 | + | html! { | |
| 1471 | + | section class="listing" { | |
| 1472 | + | h2 { "SSH and GPG keys" } | |
| 1473 | + | div class="card" { | |
| 1474 | + | p class="muted field-hint" { | |
| 1475 | + | "SSH keys authorize push over SSH; both SSH and GPG keys verify commit signatures." | |
| 1476 | + | } | |
| 1477 | + | @if keys.is_empty() { | |
| 1478 | + | p class="muted" { "No keys yet." } | |
| 1479 | + | } @else { | |
| 1480 | + | ul class="entry-list key-list" { | |
| 1481 | + | @for k in keys { | |
| 1482 | + | li class="entry-row" { | |
| 1483 | + | div class="entry-row-body" { | |
| 1484 | + | span class="entry-row-title" { | |
| 1485 | + | span class="badge" { (k.kind.as_str()) } | |
| 1486 | + | " " (k.name.as_deref().unwrap_or("(unnamed)")) | |
| 1487 | + | } | |
| 1488 | + | div class="entry-row-meta muted mono" { (k.fingerprint) } | |
| 1489 | + | } | |
| 1490 | + | div class="entry-row-actions" { | |
| 1491 | + | form method="post" action="/settings/keys/delete" { | |
| 1492 | + | input type="hidden" name="_csrf" value=(csrf); | |
| 1493 | + | input type="hidden" name="id" value=(k.id); | |
| 1494 | + | button class="btn btn-danger" type="submit" { "Delete" } | |
| 1495 | + | } | |
| 1496 | + | } | |
| 1497 | + | } | |
| 1498 | + | } | |
| 1499 | + | } | |
| 1500 | + | } | |
| 1501 | + | form class="key-add" method="post" action="/settings/keys" { | |
| 1502 | + | input type="hidden" name="_csrf" value=(csrf); | |
| 1503 | + | div class="key-add-row" { | |
| 1504 | + | label for="key_kind" { "Type" } | |
| 1505 | + | select id="key_kind" name="kind" { | |
| 1506 | + | option value="ssh" { "SSH" } | |
| 1507 | + | option value="gpg" { "GPG" } | |
| 1508 | + | } | |
| 1509 | + | label for="key_name" { "Title" } | |
| 1510 | + | input type="text" id="key_name" name="name" placeholder="Optional label"; | |
| 1511 | + | } | |
| 1512 | + | label for="key_material" { "Key" } | |
| 1513 | + | textarea id="key_material" name="key" rows="4" required | |
| 1514 | + | placeholder="Paste an SSH public key or an ASCII-armored GPG public key" {} | |
| 1515 | + | button class="btn btn-primary" type="submit" { "Add key" } | |
| 1516 | + | } | |
| 1517 | + | } | |
| 1518 | + | } | |
| 1519 | + | } | |
| 1520 | + | } | |
| 1521 | + | ||
| 1272 | 1522 | /// The API tokens settings section. | |
| 1273 | 1523 | fn tokens_section(csrf: &str, tokens: &[model::ApiToken], new_token: Option<&str>) -> Markup { | |
| 1274 | 1524 | html! { | |
crates/web/src/tests.rs +173 −0
| @@ -835,3 +835,176 @@ async fn settings_requires_authentication() { | |||
| 835 | 835 | assert_eq!(res.status(), StatusCode::SEE_OTHER); | |
| 836 | 836 | assert_eq!(res.headers().get(header::LOCATION).unwrap(), "/login"); | |
| 837 | 837 | } | |
| 838 | + | ||
| 839 | + | /// A valid SSH public key for exercising the key-management UI. | |
| 840 | + | const TEST_SSH_KEY: &str = "ssh-ed25519 \ | |
| 841 | + | AAAAC3NzaC1lZDI1NTE5AAAAIJqq6nD0E4SlM+xw1UVOompehxQE8sUYCVoyV+NBjfKU web-test@fabrica"; | |
| 842 | + | ||
| 843 | + | #[tokio::test] | |
| 844 | + | async fn settings_keys_add_list_and_delete() { | |
| 845 | + | let (state, app) = app().await; | |
| 846 | + | let cookie = login(&app).await; | |
| 847 | + | let token = cookie | |
| 848 | + | .split("fabrica_csrf=") | |
| 849 | + | .nth(1) | |
| 850 | + | .and_then(|s| s.split(';').next()) | |
| 851 | + | .unwrap() | |
| 852 | + | .to_string(); | |
| 853 | + | ||
| 854 | + | // The settings page shows the keys section. | |
| 855 | + | let req = Request::builder() | |
| 856 | + | .uri("/settings") | |
| 857 | + | .header(header::COOKIE, cookie.clone()) | |
| 858 | + | .body(Body::empty()) | |
| 859 | + | .unwrap(); | |
| 860 | + | let res = app.clone().oneshot(req).await.unwrap(); | |
| 861 | + | assert_eq!(res.status(), StatusCode::OK); | |
| 862 | + | assert!(body_string(res).await.contains("SSH and GPG keys")); | |
| 863 | + | ||
| 864 | + | // Add an SSH key. | |
| 865 | + | let body = format!( | |
| 866 | + | "kind=ssh&name=laptop&key={}&_csrf={token}", | |
| 867 | + | urlencoding(TEST_SSH_KEY) | |
| 868 | + | ); | |
| 869 | + | let req = Request::builder() | |
| 870 | + | .method("POST") | |
| 871 | + | .uri("/settings/keys") | |
| 872 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") | |
| 873 | + | .header(header::COOKIE, cookie.clone()) | |
| 874 | + | .body(Body::from(body)) | |
| 875 | + | .unwrap(); | |
| 876 | + | let res = app.clone().oneshot(req).await.unwrap(); | |
| 877 | + | assert_eq!(res.status(), StatusCode::SEE_OTHER); | |
| 878 | + | ||
| 879 | + | let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); | |
| 880 | + | let keys = state.store.keys_by_user(&ada.id).await.unwrap(); | |
| 881 | + | assert_eq!(keys.len(), 1, "key was stored"); | |
| 882 | + | let key_id = keys[0].id.clone(); | |
| 883 | + | ||
| 884 | + | // It renders on the settings page. | |
| 885 | + | let req = Request::builder() | |
| 886 | + | .uri("/settings") | |
| 887 | + | .header(header::COOKIE, cookie.clone()) | |
| 888 | + | .body(Body::empty()) | |
| 889 | + | .unwrap(); | |
| 890 | + | let res = app.clone().oneshot(req).await.unwrap(); | |
| 891 | + | assert!(body_string(res).await.contains("laptop"), "key label shown"); | |
| 892 | + | ||
| 893 | + | // Delete it. | |
| 894 | + | let req = Request::builder() | |
| 895 | + | .method("POST") | |
| 896 | + | .uri("/settings/keys/delete") | |
| 897 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") | |
| 898 | + | .header(header::COOKIE, cookie) | |
| 899 | + | .body(Body::from(format!("id={key_id}&_csrf={token}"))) | |
| 900 | + | .unwrap(); | |
| 901 | + | let res = app.oneshot(req).await.unwrap(); | |
| 902 | + | assert_eq!(res.status(), StatusCode::SEE_OTHER); | |
| 903 | + | assert!(state.store.keys_by_user(&ada.id).await.unwrap().is_empty()); | |
| 904 | + | } | |
| 905 | + | ||
| 906 | + | #[tokio::test] | |
| 907 | + | async fn settings_key_add_rejects_garbage() { | |
| 908 | + | let (_state, app) = app().await; | |
| 909 | + | let cookie = login(&app).await; | |
| 910 | + | let token = cookie | |
| 911 | + | .split("fabrica_csrf=") | |
| 912 | + | .nth(1) | |
| 913 | + | .and_then(|s| s.split(';').next()) | |
| 914 | + | .unwrap() | |
| 915 | + | .to_string(); | |
| 916 | + | let req = Request::builder() | |
| 917 | + | .method("POST") | |
| 918 | + | .uri("/settings/keys") | |
| 919 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") | |
| 920 | + | .header(header::COOKIE, cookie) | |
| 921 | + | .body(Body::from(format!( | |
| 922 | + | "kind=ssh&name=x&key=not-a-key&_csrf={token}" | |
| 923 | + | ))) | |
| 924 | + | .unwrap(); | |
| 925 | + | let res = app.oneshot(req).await.unwrap(); | |
| 926 | + | assert_eq!(res.status(), StatusCode::BAD_REQUEST); | |
| 927 | + | } | |
| 928 | + | ||
| 929 | + | /// An app with hCaptcha configured, so the sign-up form must show and enforce it. | |
| 930 | + | async fn app_with_captcha() -> Router { | |
| 931 | + | let store = Store::connect("sqlite::memory:", 1).await.unwrap(); | |
| 932 | + | store.migrate().await.unwrap(); | |
| 933 | + | let mut config = Config::default(); | |
| 934 | + | config.instance.name = "Forge".to_string(); | |
| 935 | + | config.instance.allow_registration = true; | |
| 936 | + | config.auth.cookie_secure = false; | |
| 937 | + | config.captcha.provider = config::CaptchaProvider::HCaptcha; | |
| 938 | + | config.captcha.site_key = "test-site-key".to_string(); | |
| 939 | + | config.captcha.secret_key = Some(config::Secret::new("test-secret".to_string())); | |
| 940 | + | let state = AppState::build(Arc::new(config), store, "test-secret".to_string()).unwrap(); | |
| 941 | + | crate::build_router(state) | |
| 942 | + | } | |
| 943 | + | ||
| 944 | + | #[tokio::test] | |
| 945 | + | async fn register_page_shows_the_captcha_widget() { | |
| 946 | + | let app = app_with_captcha().await; | |
| 947 | + | let res = app.oneshot(get("/register")).await.unwrap(); | |
| 948 | + | assert_eq!(res.status(), StatusCode::OK); | |
| 949 | + | let html = body_string(res).await; | |
| 950 | + | assert!(html.contains("js.hcaptcha.com"), "widget script present"); | |
| 951 | + | assert!( | |
| 952 | + | html.contains("data-sitekey=\"test-site-key\""), | |
| 953 | + | "site key present" | |
| 954 | + | ); | |
| 955 | + | assert!(html.contains("h-captcha"), "widget container present"); | |
| 956 | + | } | |
| 957 | + | ||
| 958 | + | #[tokio::test] | |
| 959 | + | async fn register_is_rejected_when_captcha_is_missing() { | |
| 960 | + | let app = app_with_captcha().await; | |
| 961 | + | let form = app.clone().oneshot(get("/register")).await.unwrap(); | |
| 962 | + | let cookie = form | |
| 963 | + | .headers() | |
| 964 | + | .get(header::SET_COOKIE) | |
| 965 | + | .unwrap() | |
| 966 | + | .to_str() | |
| 967 | + | .unwrap() | |
| 968 | + | .to_string(); | |
| 969 | + | let token = cookie | |
| 970 | + | .split("fabrica_csrf=") | |
| 971 | + | .nth(1) | |
| 972 | + | .and_then(|s| s.split(';').next()) | |
| 973 | + | .unwrap() | |
| 974 | + | .to_string(); | |
| 975 | + | // No captcha token in the body → rejected before any network call. | |
| 976 | + | let req = Request::builder() | |
| 977 | + | .method("POST") | |
| 978 | + | .uri("/register") | |
| 979 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") | |
| 980 | + | .header(header::COOKIE, format!("fabrica_csrf={token}")) | |
| 981 | + | .body(Body::from(format!( | |
| 982 | + | "username=bot&email=bot@example.com&password=secret12&confirm=secret12&_csrf={token}" | |
| 983 | + | ))) | |
| 984 | + | .unwrap(); | |
| 985 | + | let res = app.oneshot(req).await.unwrap(); | |
| 986 | + | assert_eq!(res.status(), StatusCode::BAD_REQUEST); | |
| 987 | + | assert!( | |
| 988 | + | body_string(res) | |
| 989 | + | .await | |
| 990 | + | .contains("Captcha verification failed") | |
| 991 | + | ); | |
| 992 | + | } | |
| 993 | + | ||
| 994 | + | /// Minimal application/x-www-form-urlencoded encoding for test bodies. | |
| 995 | + | fn urlencoding(s: &str) -> String { | |
| 996 | + | use std::fmt::Write as _; | |
| 997 | + | let mut out = String::with_capacity(s.len()); | |
| 998 | + | for b in s.bytes() { | |
| 999 | + | match b { | |
| 1000 | + | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { | |
| 1001 | + | out.push(b as char); | |
| 1002 | + | } | |
| 1003 | + | b' ' => out.push('+'), | |
| 1004 | + | _ => { | |
| 1005 | + | let _ = write!(out, "%{b:02X}"); | |
| 1006 | + | } | |
| 1007 | + | } | |
| 1008 | + | } | |
| 1009 | + | out | |
| 1010 | + | } | |
docs/decisions.md +28 −0
| @@ -639,3 +639,31 @@ just a private browser. Sharing the issue/PR numbering and tables keeps labels a | |||
| 639 | 639 | uniform and avoids a parallel implementation. Merge via subprocess preserves the one code path | |
| 640 | 640 | for write operations. spec.md §§1, 18–19 and CLAUDE.md are updated to match; this is a large, | |
| 641 | 641 | multi-phase build delivered incrementally with the gate green at each step. | |
| 642 | + | ||
| 643 | + | ## 2026-07-25 — Captcha on sign-up: an opt-in exception to "no network from pages" | |
| 644 | + | ||
| 645 | + | **Decision:** Implement the spec §19 optional captcha (hCaptcha / reCAPTCHA v2) on the | |
| 646 | + | `/register` form, off by default via a new `[captcha]` config table (`provider = none | | |
| 647 | + | hcaptcha | recaptcha`, `site_key`, `secret_key`). When — and only when — an admin enables it, | |
| 648 | + | the provider's widget script is loaded on the sign-up page and the solved token is verified | |
| 649 | + | server-side with an outbound HTTPS POST to the provider's `siteverify` endpoint. Verification | |
| 650 | + | **fails closed**: an empty token, a missing secret, a network error, or a rejected token all | |
| 651 | + | reject the sign-up, so a broken verifier never admits a bot. The HTTP call uses `reqwest` | |
| 652 | + | (`default-features = false`, `native-tls` + `json`) — native-tls, not rustls, so `ring` stays | |
| 653 | + | out of the graph, consistent with the mail/SSH TLS decisions. | |
| 654 | + | ||
| 655 | + | **Rationale:** captcha is fundamentally incompatible with §9.5 ("no network requests from any | |
| 656 | + | page") — it cannot work without third-party JS and an out-of-band verify. The spec nonetheless | |
| 657 | + | lists it as an optional feature, so the resolution is to make it a **deliberate, opt-in** | |
| 658 | + | deviation: the no-network invariant holds for every page by default, and enabling captcha is an | |
| 659 | + | explicit administrator choice to accept a third-party dependency on one page. No other page | |
| 660 | + | loads external resources. | |
| 661 | + | ||
| 662 | + | ## 2026-07-25 — SSH/GPG key management in the web account settings | |
| 663 | + | ||
| 664 | + | **Decision:** Surface the existing key store (CLI phase 8) in the web settings page: an "SSH | |
| 665 | + | and GPG keys" section listing the viewer's keys with delete buttons and an add form (type + | |
| 666 | + | optional label + pasted material), backed by `POST /settings/keys` and `/settings/keys/delete`. | |
| 667 | + | The add handler reuses `git::parse_public_key` for validation and fingerprinting and rejects a | |
| 668 | + | duplicate fingerprint; delete is ownership-checked against `keys_by_user`. This closes the last | |
| 669 | + | account-settings gap (§19) so key management no longer requires shell access. | |