Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +108 −0
| @@ -2321,6 +2321,23 @@ dependencies = [ | |||
| 2321 | "pin-project-lite", | 2321 | "pin-project-lite", |
| 2322 | "smallvec", | 2322 | "smallvec", |
| 2323 | "tokio", | 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 | [[package]] | 2343 | [[package]] |
| @@ -2329,13 +2346,21 @@ version = "0.1.20" | |||
| 2329 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2346 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2330 | checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" | 2347 | checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" |
| 2331 | dependencies = [ | 2348 | dependencies = [ |
| 2349 | "base64", | ||
| 2332 | "bytes", | 2350 | "bytes", |
| 2351 | "futures-channel", | ||
| 2352 | "futures-util", | ||
| 2333 | "http", | 2353 | "http", |
| 2334 | "http-body", | 2354 | "http-body", |
| 2335 | "hyper", | 2355 | "hyper", |
| 2356 | "ipnet", | ||
| 2357 | "libc", | ||
| 2358 | "percent-encoding", | ||
| 2336 | "pin-project-lite", | 2359 | "pin-project-lite", |
| 2360 | "socket2", | ||
| 2337 | "tokio", | 2361 | "tokio", |
| 2338 | "tower-service", | 2362 | "tower-service", |
| 2363 | "tracing", | ||
| 2339 | ] | 2364 | ] |
| 2340 | 2365 | ||
| 2341 | [[package]] | 2366 | [[package]] |
| @@ -2556,6 +2581,12 @@ dependencies = [ | |||
| 2556 | ] | 2581 | ] |
| 2557 | 2582 | ||
| 2558 | [[package]] | 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 | name = "is_terminal_polyfill" | 2590 | name = "is_terminal_polyfill" |
| 2560 | version = "1.70.2" | 2591 | version = "1.70.2" |
| 2561 | source = "registry+https://github.com/rust-lang/crates.io-index" | 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 | checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884" | 3881 | checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884" |
| 3851 | 3882 | ||
| 3852 | [[package]] | 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 | name = "rfc6979" | 3920 | name = "rfc6979" |
| 3854 | version = "0.4.0" | 3921 | version = "0.4.0" |
| 3855 | source = "registry+https://github.com/rust-lang/crates.io-index" | 3922 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -4099,6 +4166,15 @@ dependencies = [ | |||
| 4099 | ] | 4166 | ] |
| 4100 | 4167 | ||
| 4101 | [[package]] | 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 | name = "rustversion" | 4178 | name = "rustversion" |
| 4103 | version = "1.0.23" | 4179 | version = "1.0.23" |
| 4104 | source = "registry+https://github.com/rust-lang/crates.io-index" | 4180 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -4931,6 +5007,9 @@ name = "sync_wrapper" | |||
| 4931 | version = "1.0.2" | 5007 | version = "1.0.2" |
| 4932 | source = "registry+https://github.com/rust-lang/crates.io-index" | 5008 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 4933 | checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" | 5009 | checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" |
| 5010 | dependencies = [ | ||
| 5011 | "futures-core", | ||
| 5012 | ] | ||
| 4934 | 5013 | ||
| 4935 | [[package]] | 5014 | [[package]] |
| 4936 | name = "synstructure" | 5015 | name = "synstructure" |
| @@ -5219,14 +5298,17 @@ dependencies = [ | |||
| 5219 | "bitflags", | 5298 | "bitflags", |
| 5220 | "bytes", | 5299 | "bytes", |
| 5221 | "futures-core", | 5300 | "futures-core", |
| 5301 | "futures-util", | ||
| 5222 | "http", | 5302 | "http", |
| 5223 | "http-body", | 5303 | "http-body", |
| 5224 | "pin-project-lite", | 5304 | "pin-project-lite", |
| 5225 | "tokio", | 5305 | "tokio", |
| 5226 | "tokio-util", | 5306 | "tokio-util", |
| 5307 | "tower", | ||
| 5227 | "tower-layer", | 5308 | "tower-layer", |
| 5228 | "tower-service", | 5309 | "tower-service", |
| 5229 | "tracing", | 5310 | "tracing", |
| 5311 | "url", | ||
| 5230 | "uuid", | 5312 | "uuid", |
| 5231 | ] | 5313 | ] |
| 5232 | 5314 | ||
| @@ -5348,6 +5430,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 5348 | checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" | 5430 | checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" |
| 5349 | 5431 | ||
| 5350 | [[package]] | 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 | name = "twofish" | 5439 | name = "twofish" |
| 5352 | version = "0.7.1" | 5440 | version = "0.7.1" |
| 5353 | source = "registry+https://github.com/rust-lang/crates.io-index" | 5441 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -5528,6 +5616,15 @@ dependencies = [ | |||
| 5528 | ] | 5616 | ] |
| 5529 | 5617 | ||
| 5530 | [[package]] | 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 | name = "wasi" | 5628 | name = "wasi" |
| 5532 | version = "0.11.1+wasi-snapshot-preview1" | 5629 | version = "0.11.1+wasi-snapshot-preview1" |
| 5533 | source = "registry+https://github.com/rust-lang/crates.io-index" | 5630 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -5618,6 +5715,7 @@ dependencies = [ | |||
| 5618 | "maud", | 5715 | "maud", |
| 5619 | "mime_guess", | 5716 | "mime_guess", |
| 5620 | "model", | 5717 | "model", |
| 5718 | "reqwest", | ||
| 5621 | "rust-embed", | 5719 | "rust-embed", |
| 5622 | "serde", | 5720 | "serde", |
| 5623 | "serde_json", | 5721 | "serde_json", |
| @@ -5635,6 +5733,16 @@ dependencies = [ | |||
| 5635 | ] | 5733 | ] |
| 5636 | 5734 | ||
| 5637 | [[package]] | 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 | name = "web-time" | 5746 | name = "web-time" |
| 5639 | version = "1.1.0" | 5747 | version = "1.1.0" |
| 5640 | source = "registry+https://github.com/rust-lang/crates.io-index" | 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 | flate2 = "1" | 160 | flate2 = "1" |
| 161 | # Content search (ripgrep's engine). Dual Unlicense/MIT — MIT satisfies the gate. | 161 | # Content search (ripgrep's engine). Dual Unlicense/MIT — MIT satisfies the gate. |
| 162 | grep = "0.3" | 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 | # SSH server (default features: aws-lc-rs backend, no ring; re-exports ssh-key). | 170 | # SSH server (default features: aws-lc-rs backend, no ring; re-exports ssh-key). |
| 164 | russh = "0.62" | 171 | russh = "0.62" |
| 165 | tracing = "0.1" | 172 | tracing = "0.1" |
assets/base.css +17 −0
| @@ -592,6 +592,23 @@ select:focus { | |||
| 592 | .token-list { | 592 | .token-list { |
| 593 | margin-bottom: 1rem; | 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 | .token-scopes { | 612 | .token-scopes { |
| 596 | margin: 0.75rem 0 0; | 613 | margin: 0.75rem 0 0; |
| 597 | padding: 0.5rem 0.85rem; | 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 | pub use crate::secret::{REDACTED, Secret}; | 33 | pub use crate::secret::{REDACTED, Secret}; |
| 34 | pub use crate::types::{ | 34 | pub use crate::types::{ |
| 35 | Auth, Config, Database, DefaultVisibility, Git, Instance, Log, LogFormat, LogLevel, Mail, | 35 | Auth, Captcha, CaptchaProvider, Config, Database, DefaultVisibility, Git, Instance, Log, |
| 36 | MailBackend, MailEncryption, Search, Server, Ssh, Storage, Ui, | 36 | LogFormat, LogLevel, Mail, MailBackend, MailEncryption, Search, Server, Ssh, Storage, Ui, |
| 37 | }; | 37 | }; |
| 38 | 38 | ||
| 39 | /// System-wide configuration path, the lowest-priority file source. | 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 | pub auth: Auth, | 32 | pub auth: Auth, |
| 33 | /// Outbound mail. | 33 | /// Outbound mail. |
| 34 | pub mail: Mail, | 34 | pub mail: Mail, |
| 35 | /// Optional bot mitigation on self-registration. | ||
| 36 | #[serde(default)] | ||
| 37 | pub captcha: Captcha, | ||
| 35 | /// Web UI presentation. | 38 | /// Web UI presentation. |
| 36 | pub ui: Ui, | 39 | pub ui: Ui, |
| 37 | /// Git binary and pack-transport limits. | 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 | /// `[server]` — HTTP binding and limits. | 214 | /// `[server]` — HTTP binding and limits. |
| 130 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | 215 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 131 | #[serde(deny_unknown_fields)] | 216 | #[serde(deny_unknown_fields)] |
crates/web/Cargo.toml +1 −0
| @@ -30,6 +30,7 @@ ammonia = { workspace = true } | |||
| 30 | tokio-util = { workspace = true } | 30 | tokio-util = { workspace = true } |
| 31 | flate2 = { workspace = true } | 31 | flate2 = { workspace = true } |
| 32 | grep = { workspace = true } | 32 | grep = { workspace = true } |
| 33 | reqwest = { workspace = true } | ||
| 33 | base64 = { workspace = true } | 34 | base64 = { workspace = true } |
| 34 | time = { workspace = true } | 35 | time = { workspace = true } |
| 35 | serde = { workspace = true } | 36 | serde = { workspace = true } |
crates/web/src/lib.rs +2 −0
| @@ -157,6 +157,8 @@ pub fn build_router(state: AppState) -> Router { | |||
| 157 | .route("/settings/password", post(pages::account_password)) | 157 | .route("/settings/password", post(pages::account_password)) |
| 158 | .route("/settings/tokens", post(pages::token_create)) | 158 | .route("/settings/tokens", post(pages::token_create)) |
| 159 | .route("/settings/tokens/revoke", post(pages::token_revoke)) | 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 | .route("/settings/avatar", post(avatar::upload)) | 162 | .route("/settings/avatar", post(avatar::upload)) |
| 161 | .route("/settings/theme", get(pages::set_theme)) | 163 | .route("/settings/theme", get(pages::set_theme)) |
| 162 | .route("/avatar/{username}", get(avatar::show)) | 164 | .route("/avatar/{username}", get(avatar::show)) |
crates/web/src/pages.rs +257 −7
| @@ -12,7 +12,7 @@ use axum::extract::{Path, State}; | |||
| 12 | use axum::http::{HeaderMap, Uri, header}; | 12 | use axum::http::{HeaderMap, Uri, header}; |
| 13 | use axum::response::{IntoResponse, Redirect, Response}; | 13 | use axum::response::{IntoResponse, Redirect, Response}; |
| 14 | use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; | 14 | use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; |
| 15 | use maud::{Markup, html}; | 15 | use maud::{Markup, PreEscaped, html}; |
| 16 | use model::User; | 16 | use model::User; |
| 17 | use serde::Deserialize; | 17 | use serde::Deserialize; |
| 18 | 18 | ||
| @@ -559,11 +559,25 @@ pub struct RegisterForm { | |||
| 559 | /// Confirmation. | 559 | /// Confirmation. |
| 560 | #[serde(default)] | 560 | #[serde(default)] |
| 561 | confirm: String, | 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 | /// CSRF token. | 568 | /// CSRF token. |
| 563 | #[serde(rename = "_csrf")] | 569 | #[serde(rename = "_csrf")] |
| 564 | csrf: String, | 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 | /// `GET /register` — the sign-up form, or `404` when registration is disabled. | 581 | /// `GET /register` — the sign-up form, or `404` when registration is disabled. |
| 568 | pub async fn register_form( | 582 | pub async fn register_form( |
| 569 | State(state): State<AppState>, | 583 | State(state): State<AppState>, |
| @@ -578,12 +592,25 @@ pub async fn register_form( | |||
| 578 | return Redirect::to("/").into_response(); | 592 | return Redirect::to("/").into_response(); |
| 579 | } | 593 | } |
| 580 | let (jar, chrome) = build_chrome(&state, jar, None, uri.path().to_string()); | 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 | (jar, page(&chrome, "Sign up", body)).into_response() | 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. | 605 | /// Render the sign-up form with an optional error, prefilled fields, and the |
| 586 | fn register_body(csrf: &str, error: Option<&str>, username: &str, email: &str) -> Markup { | 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 | html! { | 614 | html! { |
| 588 | div class="auth-wrap" { | 615 | div class="auth-wrap" { |
| 589 | div class="auth-card" { | 616 | div class="auth-card" { |
| @@ -605,6 +632,12 @@ fn register_body(csrf: &str, error: Option<&str>, username: &str, email: &str) - | |||
| 605 | label for="confirm" { "Confirm password" } | 632 | label for="confirm" { "Confirm password" } |
| 606 | input type="password" id="confirm" name="confirm" | 633 | input type="password" id="confirm" name="confirm" |
| 607 | autocomplete="new-password" required; | 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 | button class="btn btn-primary" type="submit" { "Create account" } | 641 | button class="btn btn-primary" type="submit" { "Create account" } |
| 609 | } | 642 | } |
| 610 | p class="muted auth-alt" { "Already have an account? " a href="/login" { "Sign in" } } | 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 | let username = form.username.trim().to_string(); | 662 | let username = form.username.trim().to_string(); |
| 630 | let email = form.email.trim().to_string(); | 663 | let email = form.email.trim().to_string(); |
| 631 | 664 | ||
| 665 | let captcha = captcha_view(&state.config); | ||
| 632 | let reject = |msg: &str, username: &str, email: &str| { | 666 | let reject = |msg: &str, username: &str, email: &str| { |
| 633 | let (jar, chrome) = build_chrome(&state, jar.clone(), None, "/register".to_string()); | 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 | axum::http::StatusCode::BAD_REQUEST, | 670 | axum::http::StatusCode::BAD_REQUEST, |
| 637 | jar, | 671 | jar, |
| @@ -650,6 +684,22 @@ pub async fn register_submit( | |||
| 650 | return reject("Password must be at least 8 characters.", &username, &email); | 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 | let params = auth::HashParams { | 703 | let params = auth::HashParams { |
| 654 | m_cost: state.config.auth.argon2_m_cost, | 704 | m_cost: state.config.auth.argon2_m_cost, |
| 655 | t_cost: state.config.auth.argon2_t_cost, | 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 | /// Logout form field. | 785 | /// Logout form field. |
| 700 | #[derive(Debug, Deserialize)] | 786 | #[derive(Debug, Deserialize)] |
| 701 | pub struct CsrfForm { | 787 | pub struct CsrfForm { |
| @@ -841,8 +927,9 @@ pub async fn settings( | |||
| 841 | uri: Uri, | 927 | uri: Uri, |
| 842 | ) -> AppResult<Response> { | 928 | ) -> AppResult<Response> { |
| 843 | let tokens = state.store.tokens_by_user(&user.id).await?; | 929 | let tokens = state.store.tokens_by_user(&user.id).await?; |
| 930 | let keys = state.store.keys_by_user(&user.id).await?; | ||
| 844 | let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string()); | 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 | Ok((jar, page(&chrome, "Settings", body)).into_response()) | 933 | Ok((jar, page(&chrome, "Settings", body)).into_response()) |
| 847 | } | 934 | } |
| 848 | 935 | ||
| @@ -1129,9 +1216,10 @@ pub async fn token_create( | |||
| 1129 | .tokens_by_user(&user.id) | 1216 | .tokens_by_user(&user.id) |
| 1130 | .await | 1217 | .await |
| 1131 | .unwrap_or_default(); | 1218 | .unwrap_or_default(); |
| 1219 | let keys = state.store.keys_by_user(&user.id).await.unwrap_or_default(); | ||
| 1132 | let (jar, chrome) = | 1220 | let (jar, chrome) = |
| 1133 | build_chrome(&state, jar, Some(user.clone()), "/settings".to_string()); | 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 | (jar, page(&chrome, "Settings", body)).into_response() | 1223 | (jar, page(&chrome, "Settings", body)).into_response() |
| 1136 | } | 1224 | } |
| 1137 | Err(err) => err.into_response(), | 1225 | Err(err) => err.into_response(), |
| @@ -1174,11 +1262,118 @@ pub async fn token_revoke( | |||
| 1174 | Redirect::to("/settings").into_response() | 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 | /// Render the settings page: avatar, profile, account credentials, and tokens. | 1371 | /// Render the settings page: avatar, profile, account credentials, and tokens. |
| 1178 | fn settings_body( | 1372 | fn settings_body( |
| 1179 | user: &User, | 1373 | user: &User, |
| 1180 | csrf: &str, | 1374 | csrf: &str, |
| 1181 | tokens: &[model::ApiToken], | 1375 | tokens: &[model::ApiToken], |
| 1376 | keys: &[model::Key], | ||
| 1182 | new_token: Option<&str>, | 1377 | new_token: Option<&str>, |
| 1183 | ) -> Markup { | 1378 | ) -> Markup { |
| 1184 | let val = |v: &Option<String>| v.clone().unwrap_or_default(); | 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 | (tokens_section(csrf, tokens, new_token)) | 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 | /// The API tokens settings section. | 1522 | /// The API tokens settings section. |
| 1273 | fn tokens_section(csrf: &str, tokens: &[model::ApiToken], new_token: Option<&str>) -> Markup { | 1523 | fn tokens_section(csrf: &str, tokens: &[model::ApiToken], new_token: Option<&str>) -> Markup { |
| 1274 | html! { | 1524 | html! { |
crates/web/src/tests.rs +173 −0
| @@ -835,3 +835,176 @@ async fn settings_requires_authentication() { | |||
| 835 | assert_eq!(res.status(), StatusCode::SEE_OTHER); | 835 | assert_eq!(res.status(), StatusCode::SEE_OTHER); |
| 836 | assert_eq!(res.headers().get(header::LOCATION).unwrap(), "/login"); | 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 | uniform and avoids a parallel implementation. Merge via subprocess preserves the one code path | 639 | uniform and avoids a parallel implementation. Merge via subprocess preserves the one code path |
| 640 | for write operations. spec.md §§1, 18–19 and CLAUDE.md are updated to match; this is a large, | 640 | for write operations. spec.md §§1, 18–19 and CLAUDE.md are updated to match; this is a large, |
| 641 | multi-phase build delivered incrementally with the gate green at each step. | 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. | ||