Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8223cd4445 | |||
| c1187a7926 | |||
| f1f605e180 | |||
| 3625cf48fb |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.8.3] - 2026-09-19
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- The daemon no longer prints the web token when it starts, so it stays out of `docker logs`. It
|
||||||
|
says where the token is kept instead: `[web] token` in config.toml.
|
||||||
|
- Signing in through Cloudflare Access can check the token Access signs: set `access_team` and
|
||||||
|
`access_aud` under `[web]`, and a request has to carry a valid `Cf-Access-Jwt-Assertion` as well as
|
||||||
|
the email header. Without it, anything on the same Docker host as ipx could send the header. See
|
||||||
|
docs/sso.md.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A Substack post shows its subtitle above the post, as Substack does. Only posts that arrive from
|
||||||
|
now on have it.
|
||||||
|
|
||||||
## [0.8.2] - 2026-09-19
|
## [0.8.2] - 2026-09-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -540,6 +556,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
- `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`.
|
||||||
|
|
||||||
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...main
|
[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.6.1...main
|
||||||
|
[0.8.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.2...v0.8.3
|
||||||
[0.8.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.1...v0.8.2
|
[0.8.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.1...v0.8.2
|
||||||
[0.8.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.0...v0.8.1
|
[0.8.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.8.0...v0.8.1
|
||||||
[0.8.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.7.0...v0.8.0
|
[0.8.0]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.7.0...v0.8.0
|
||||||
|
|||||||
@@ -183,10 +183,7 @@ Deliberate simplifications get a `ponytail:` comment naming the ceiling and the
|
|||||||
|
|
||||||
## Known gaps
|
## Known gaps
|
||||||
|
|
||||||
* Cloudflare's `Cf-Access-Jwt-Assertion` is not verified — ipx trusts the hop plus `trusted_proxies`
|
* Nothing at the moment. Add one here when a limitation is known and left in place.
|
||||||
(documented in [docs/sso.md](docs/sso.md)).
|
|
||||||
* A feed's `<description>` subtitle is dropped whenever `content:encoded` exists, which loses
|
|
||||||
Substack-style subtitles.
|
|
||||||
|
|
||||||
<!-- rtk-instructions v2 -->
|
<!-- rtk-instructions v2 -->
|
||||||
# Command output
|
# Command output
|
||||||
|
|||||||
42
Cargo.lock
generated
42
Cargo.lock
generated
@@ -1820,7 +1820,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.8.2"
|
version = "0.8.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
"ammonia",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -1830,6 +1830,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"jsonwebtoken",
|
||||||
"librqbit",
|
"librqbit",
|
||||||
"opml",
|
"opml",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
@@ -2007,6 +2008,22 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonwebtoken"
|
||||||
|
version = "11.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
|
||||||
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
|
"base64 0.22.1",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"js-sys",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"signature",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -3786,6 +3803,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signature"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.10"
|
version = "0.3.10"
|
||||||
@@ -5183,6 +5209,20 @@ name = "zeroize"
|
|||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize_derive"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.8.2"
|
version = "0.8.3"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -12,6 +12,7 @@ axum = "0.8.9"
|
|||||||
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
||||||
clap = { version = "4.6.6", features = ["derive"] }
|
clap = { version = "4.6.6", features = ["derive"] }
|
||||||
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
|
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
|
||||||
|
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] }
|
||||||
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
|
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
|
||||||
opml = "1.1.6"
|
opml = "1.1.6"
|
||||||
percent-encoding = "2.3.2"
|
percent-encoding = "2.3.2"
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ bind = "0.0.0.0:8099" # 127.0.0.1:8080 by default
|
|||||||
token = "" # generated and saved on first run
|
token = "" # generated and saved on first run
|
||||||
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
|
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
|
||||||
trusted_proxies = ["127.0.0.1", "::1"]
|
trusted_proxies = ["127.0.0.1", "::1"]
|
||||||
|
access_team = "" # e.g. "<team>.cloudflareaccess.com"
|
||||||
|
access_aud = "" # the Access application's AUD tag
|
||||||
auto_create_users = true
|
auto_create_users = true
|
||||||
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
|
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
|
||||||
session_days = 30
|
session_days = 30
|
||||||
@@ -94,6 +96,9 @@ session_days = 30
|
|||||||
disables that path. See [sso.md](sso.md).
|
disables that path. See [sso.md](sso.md).
|
||||||
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
|
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
|
||||||
for it. Name the proxy, never a subnet.
|
for it. Name the proxy, never a subnet.
|
||||||
|
* **`access_team`**, **`access_aud`** — with both set, a request through the proxy also has to
|
||||||
|
carry the `Cf-Access-Jwt-Assertion` Cloudflare Access signed for this application, and the name
|
||||||
|
comes from that token instead of the header. See [sso.md](sso.md#verifying-cloudflares-token).
|
||||||
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
|
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
|
||||||
* **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out,
|
* **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out,
|
||||||
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
|
`/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where
|
||||||
|
|||||||
30
docs/sso.md
30
docs/sso.md
@@ -172,9 +172,33 @@ itself, arrive under their own addresses and cannot set the header; the checks a
|
|||||||
sides. Never list a LAN address or range: anyone there could then send
|
sides. Never list a LAN address or range: anyone there could then send
|
||||||
`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you.
|
`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you.
|
||||||
|
|
||||||
**What ipx does not do:** it does not verify Cloudflare's signed `Cf-Access-Jwt-Assertion`. It
|
**Unless the token is checked.** With `access_team` and `access_aud` set (next section), the
|
||||||
trusts the hop. Verifying the signature would make the containers on Tower irrelevant to the
|
header is not enough on its own: the request has to carry the token Cloudflare Access signed, and
|
||||||
boundary, and is the upgrade if that ever matters.
|
a container on Tower cannot make one.
|
||||||
|
|
||||||
|
### Verifying Cloudflare's token
|
||||||
|
|
||||||
|
Access adds `Cf-Access-Jwt-Assertion` to every request it forwards: a JWT naming the person,
|
||||||
|
signed with keys only Cloudflare holds. With these two settings ipx checks it on every proxied
|
||||||
|
request, and takes the name from its `email` claim.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[web]
|
||||||
|
access_team = "<team>.cloudflareaccess.com" # Zero Trust → Settings: the team domain
|
||||||
|
access_aud = "…" # Access → Applications → ipodderx → Overview: Application Audience (AUD) Tag
|
||||||
|
```
|
||||||
|
|
||||||
|
ipx fetches the public keys from `https://<access_team>/cdn-cgi/access/certs` when it starts, and
|
||||||
|
again when a token names a key it has not seen (Cloudflare rotates them every six weeks or so), at
|
||||||
|
most once a minute. It checks the signature (RS256 only), that the audience is this application's
|
||||||
|
tag, the issuer, and the expiry. Anything else is refused, and so is every proxied request while
|
||||||
|
the keys cannot be fetched; password and token sign-in still work then.
|
||||||
|
|
||||||
|
`trusted_header` and `trusted_proxies` still apply: the check is added to them, not put in their
|
||||||
|
place.
|
||||||
|
|
||||||
|
Check it: the busybox request under [Check it](#check-it), which sends the email header without a
|
||||||
|
token from the Docker bridge, now gets `sign in`, and the site still signs you in through Authentik.
|
||||||
|
|
||||||
**Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign
|
**Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign
|
||||||
in with them until they are given a password (`ipx user passwd <name>`).
|
in with them until they are given a password (`ipx user passwd <name>`).
|
||||||
|
|||||||
215
src/access.rs
Normal file
215
src/access.rs
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
//! Cloudflare Access's signed assertion, `Cf-Access-Jwt-Assertion`. Without it, the proxy
|
||||||
|
//! sign-in trusts a plain header from any address in `trusted_proxies`, and on Tower that
|
||||||
|
//! address is the Docker gateway: any container there could send the header and be anyone.
|
||||||
|
//! Access signs the same identity with keys only Cloudflare holds, so checking that signature
|
||||||
|
//! takes the network out of the question.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::future::Future;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use jsonwebtoken::jwk::JwkSet;
|
||||||
|
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
|
||||||
|
|
||||||
|
/// Cloudflare rotates its keys every six weeks or so, publishing the new one before using it.
|
||||||
|
/// A token naming a key not seen yet refetches, but no more often than this, so a stream of
|
||||||
|
/// made-up key ids cannot turn every request into a request to Cloudflare.
|
||||||
|
const REFETCH_EVERY: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Keys {
|
||||||
|
cache: Mutex<Cache>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Cache {
|
||||||
|
keys: HashMap<String, DecodingKey>,
|
||||||
|
fetched: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Keys {
|
||||||
|
/// The name the token vouches for, or None: a bad signature, the wrong audience or issuer, an
|
||||||
|
/// expired token, a key that cannot be had, or no email in it (a service token has none).
|
||||||
|
pub async fn verify(&self, client: &reqwest::Client, team: &str, aud: &str, token: &str) -> Option<String> {
|
||||||
|
self.verify_with(team, aud, token, || fetch(client, team)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill the cache before the first request needs it. A failure is only logged: the next
|
||||||
|
/// request tries again, and until one succeeds the proxy sign-in refuses everyone.
|
||||||
|
pub async fn prefetch(&self, client: &reqwest::Client, team: &str) {
|
||||||
|
match fetch(client, team).await {
|
||||||
|
Ok(set) => self.store(set),
|
||||||
|
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_with<F, Fut>(&self, team: &str, aud: &str, token: &str, fetch: F) -> Option<String>
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Fut,
|
||||||
|
Fut: Future<Output = Result<JwkSet>>,
|
||||||
|
{
|
||||||
|
let kid = decode_header(token).ok()?.kid?;
|
||||||
|
let key = match self.key(&kid) {
|
||||||
|
Some(k) => k,
|
||||||
|
None => {
|
||||||
|
if !self.may_refetch() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match fetch().await {
|
||||||
|
Ok(set) => self.store(set),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.key(&kid)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// RS256 only: a token that names HS256 or none is refused here, before its signature
|
||||||
|
// is looked at, rather than checked with the public key as if it were a secret.
|
||||||
|
let mut v = Validation::new(Algorithm::RS256);
|
||||||
|
v.set_audience(&[aud]);
|
||||||
|
v.set_issuer(&[format!("https://{team}")]);
|
||||||
|
v.validate_nbf = true;
|
||||||
|
match decode::<Claims>(token, &key, &v) {
|
||||||
|
Ok(data) => crate::auth::name_from_header(&data.claims.email?),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "refused a Cloudflare Access token");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(&self, kid: &str) -> Option<DecodingKey> {
|
||||||
|
self.cache.lock().unwrap().keys.get(kid).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes the slot as it answers, so two requests at once do not both fetch.
|
||||||
|
fn may_refetch(&self) -> bool {
|
||||||
|
let mut c = self.cache.lock().unwrap();
|
||||||
|
if c.fetched.is_some_and(|t| t.elapsed() < REFETCH_EVERY) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
c.fetched = Some(Instant::now());
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the whole set, so a key Cloudflare has retired stops being accepted.
|
||||||
|
fn store(&self, set: JwkSet) {
|
||||||
|
let keys = set
|
||||||
|
.keys
|
||||||
|
.iter()
|
||||||
|
.filter_map(|k| Some((k.common.key_id.clone()?, DecodingKey::from_jwk(k).ok()?)))
|
||||||
|
.collect();
|
||||||
|
let mut c = self.cache.lock().unwrap();
|
||||||
|
c.keys = keys;
|
||||||
|
c.fetched = Some(Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch(client: &reqwest::Client, team: &str) -> Result<JwkSet> {
|
||||||
|
let url = format!("https://{team}/cdn-cgi/access/certs");
|
||||||
|
client
|
||||||
|
.get(&url)
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("fetching {url}"))?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.context("reading the signing keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use jsonwebtoken::{EncodingKey, Header, encode, get_current_timestamp};
|
||||||
|
|
||||||
|
const TEAM: &str = "team.cloudflareaccess.com";
|
||||||
|
const AUD: &str = "aud-tag";
|
||||||
|
|
||||||
|
fn jwks() -> JwkSet {
|
||||||
|
serde_json::from_str(include_str!("../tests/data/access-test.jwks.json")).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token(key: &[u8], alg: Algorithm, claims: serde_json::Value) -> String {
|
||||||
|
let mut h = Header::new(alg);
|
||||||
|
h.kid = Some("k1".into());
|
||||||
|
let k = if alg == Algorithm::RS256 { EncodingKey::from_rsa_der(key) } else { EncodingKey::from_secret(key) };
|
||||||
|
encode(&h, &claims, &k).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn claims(aud: &str, exp_in: i64) -> serde_json::Value {
|
||||||
|
let now = get_current_timestamp() as i64;
|
||||||
|
serde_json::json!({
|
||||||
|
"aud": [aud], "iss": format!("https://{TEAM}"), "email": "Rays@SDF1.net",
|
||||||
|
"iat": now, "nbf": now, "exp": now + exp_in, "type": "app",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIGNER: &[u8] = include_bytes!("../tests/data/access-test.der");
|
||||||
|
const FORGER: &[u8] = include_bytes!("../tests/data/access-forger.der");
|
||||||
|
|
||||||
|
async fn check(keys: &Keys, t: &str) -> Option<String> {
|
||||||
|
keys.verify_with(TEAM, AUD, t, || async { Ok(jwks()) }).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn only_a_token_cloudflare_signed_for_this_app_signs_anyone_in() {
|
||||||
|
let keys = Keys::default();
|
||||||
|
keys.store(jwks());
|
||||||
|
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||||
|
assert_eq!(check(&keys, &ok).await.as_deref(), Some("rays@sdf1.net"), "lower-cased like the header");
|
||||||
|
|
||||||
|
let other_app = token(SIGNER, Algorithm::RS256, claims("another-app", 300));
|
||||||
|
assert_eq!(check(&keys, &other_app).await, None, "an Access token for another application");
|
||||||
|
|
||||||
|
let expired = token(SIGNER, Algorithm::RS256, claims(AUD, -3600));
|
||||||
|
assert_eq!(check(&keys, &expired).await, None, "expired");
|
||||||
|
|
||||||
|
let forged = token(FORGER, Algorithm::RS256, claims(AUD, 300));
|
||||||
|
assert_eq!(check(&keys, &forged).await, None, "signed by a key that is not Cloudflare's");
|
||||||
|
|
||||||
|
// HMAC and none, the classic ways to get a token past a verifier that trusts its header.
|
||||||
|
let hs = token(b"any secret at all", Algorithm::HS256, claims(AUD, 300));
|
||||||
|
assert_eq!(check(&keys, &hs).await, None, "HS256");
|
||||||
|
let none = format!("{}.{}.", "eyJhbGciOiJub25lIiwia2lkIjoiazEifQ",
|
||||||
|
ok.split('.').nth(1).unwrap());
|
||||||
|
assert_eq!(check(&keys, &none).await, None, "alg none");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_unknown_key_refetches_once_a_minute_at_most() {
|
||||||
|
let keys = Keys::default();
|
||||||
|
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||||
|
let fetches = std::sync::atomic::AtomicUsize::new(0);
|
||||||
|
let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } };
|
||||||
|
assert_eq!(keys.verify_with(TEAM, AUD, &ok, count).await.as_deref(), Some("rays@sdf1.net"),
|
||||||
|
"a key not cached yet is fetched");
|
||||||
|
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||||
|
|
||||||
|
// A made-up key id straight after: not fetched again.
|
||||||
|
let mut h = Header::new(Algorithm::RS256);
|
||||||
|
h.kid = Some("nobody".into());
|
||||||
|
let stray = encode(&h, &claims(AUD, 300), &EncodingKey::from_rsa_der(SIGNER)).unwrap();
|
||||||
|
let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } };
|
||||||
|
assert_eq!(keys.verify_with(TEAM, AUD, &stray, count).await, None);
|
||||||
|
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1, "rate-limited");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn keys_that_cannot_be_fetched_refuse_rather_than_wave_through() {
|
||||||
|
let keys = Keys::default();
|
||||||
|
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
|
||||||
|
let got = keys.verify_with(TEAM, AUD, &ok, || async { Err(anyhow::anyhow!("offline")) }).await;
|
||||||
|
assert_eq!(got, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,14 @@ pub struct Web {
|
|||||||
/// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could
|
/// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could
|
||||||
/// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon.
|
/// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon.
|
||||||
pub trusted_proxies: Vec<String>,
|
pub trusted_proxies: Vec<String>,
|
||||||
|
/// Cloudflare Access's team domain, `<team>.cloudflareaccess.com`. With `access_aud`, the
|
||||||
|
/// proxy sign-in also needs the `Cf-Access-Jwt-Assertion` Access signs, and takes the name
|
||||||
|
/// from it: a header from a trusted address is otherwise all it asks for, and on a Docker
|
||||||
|
/// host any container can send one from the gateway's address.
|
||||||
|
pub access_team: String,
|
||||||
|
/// The Access application's Application Audience (AUD) tag. Empty, with `access_team`,
|
||||||
|
/// leaves the signature unchecked.
|
||||||
|
pub access_aud: String,
|
||||||
/// Create an account the first time the proxy vouches for a name it has not seen.
|
/// Create an account the first time the proxy vouches for a name it has not seen.
|
||||||
pub auto_create_users: bool,
|
pub auto_create_users: bool,
|
||||||
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
|
/// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
|
||||||
@@ -96,6 +104,8 @@ impl Default for Web {
|
|||||||
token: String::new(),
|
token: String::new(),
|
||||||
trusted_header: String::new(),
|
trusted_header: String::new(),
|
||||||
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
|
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
|
||||||
|
access_team: String::new(),
|
||||||
|
access_aud: String::new(),
|
||||||
auto_create_users: true,
|
auto_create_users: true,
|
||||||
sign_out_url: String::new(),
|
sign_out_url: String::new(),
|
||||||
session_days: 30,
|
session_days: 30,
|
||||||
@@ -107,6 +117,12 @@ impl Web {
|
|||||||
pub fn binds_publicly(&self) -> bool {
|
pub fn binds_publicly(&self) -> bool {
|
||||||
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
|
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Both halves of the Access check, or None while either is unset.
|
||||||
|
pub fn access(&self) -> Option<(&str, &str)> {
|
||||||
|
(!self.access_team.is_empty() && !self.access_aud.is_empty())
|
||||||
|
.then_some((self.access_team.as_str(), self.access_aud.as_str()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
|||||||
77
src/feed.rs
77
src/feed.rs
@@ -698,10 +698,51 @@ fn title_text(s: Option<&str>) -> Option<String> {
|
|||||||
/// began halfway through a tag and the page showed the rest of the tag as text. The same item's
|
/// began halfway through a tag and the page showed the rest of the tag as text. The same item's
|
||||||
/// `description` was whole. With no description to fall back on, a damaged body beats none.
|
/// `description` was whole. With no description to fall back on, a damaged body beats none.
|
||||||
fn body(content: Option<&str>, description: Option<&str>) -> Option<String> {
|
fn body(content: Option<&str>, description: Option<&str>) -> Option<String> {
|
||||||
non_empty(content)
|
match non_empty(content).filter(|c| !starts_mid_tag(c)) {
|
||||||
.filter(|c| !starts_mid_tag(c))
|
Some(c) => Some(match subtitle(&c, description) {
|
||||||
.or_else(|| non_empty(description))
|
Some(s) => format!("<p><em>{}</em></p>{c}", quick_xml::escape::escape(s.as_str())),
|
||||||
.or_else(|| non_empty(content))
|
None => c,
|
||||||
|
}),
|
||||||
|
None => non_empty(description).or_else(|| non_empty(content)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A description that is a subtitle rather than a second copy of the notes: Substack puts the
|
||||||
|
/// post's subtitle there and leaves it out of `content:encoded`, so taking the body alone lost it.
|
||||||
|
/// Podcast feeds mostly repeat their notes in both, whole or cut short with an ellipsis, and a
|
||||||
|
/// description found in the body is not shown twice.
|
||||||
|
///
|
||||||
|
/// ponytail: short plain text not found in the body. A summary a podcast writes apart from its
|
||||||
|
/// notes passes too and shows above them, which reads fine; a real subtitle field would need an
|
||||||
|
/// `entries` column.
|
||||||
|
fn subtitle(body: &str, description: Option<&str>) -> Option<String> {
|
||||||
|
let d = title_text(description)?;
|
||||||
|
if d.contains('<') || d.chars().count() > 300 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Words alone: a tag taken out leaves "tape ," where the description has "tape,", and a cut
|
||||||
|
// description ends in "…" or "[...]".
|
||||||
|
let words = |s: &str| {
|
||||||
|
s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ").to_lowercase()
|
||||||
|
};
|
||||||
|
let want = words(&d);
|
||||||
|
let text = title_text(Some(&text_of(body))).unwrap_or_default();
|
||||||
|
(!want.is_empty() && !words(&text).contains(&want)).then_some(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HTML with its tags taken out, each replaced by a space so words either side stay apart.
|
||||||
|
fn text_of(html: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(html.len());
|
||||||
|
let mut in_tag = false;
|
||||||
|
for c in html.chars() {
|
||||||
|
match c {
|
||||||
|
'<' => in_tag = true,
|
||||||
|
'>' if in_tag => { in_tag = false; out.push(' '); }
|
||||||
|
_ if !in_tag => out.push(c),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Text that closes an attribute list (`">`) before any tag has opened is the tail of a tag whose
|
/// Text that closes an attribute list (`">`) before any tag has opened is the tail of a tag whose
|
||||||
@@ -841,12 +882,36 @@ mod tests {
|
|||||||
let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant"> <p>What if</p>"#;
|
let cut = r#"*]:pointer-events-auto R6Vx5W_threadScrollVars" dir="auto" data-turn="assistant"> <p>What if</p>"#;
|
||||||
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
|
let whole = r#"<div class="[&:has([data-writing-block])>*]:pointer-events-auto"><p>What if</p></div>"#;
|
||||||
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
|
assert_eq!(body(Some(cut), Some(whole)).as_deref(), Some(whole));
|
||||||
assert_eq!(body(Some("<p>Notes</p>"), Some("Summary")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
|
assert_eq!(body(Some("<p>Notes</p>"), Some("<p>Notes</p>")).as_deref(), Some("<p>Notes</p>"), "a whole body wins");
|
||||||
assert_eq!(body(Some("Plain notes, no tags."), Some("Summary")).as_deref(), Some("Plain notes, no tags."));
|
assert_eq!(body(Some("Plain notes, no tags."), Some("Plain notes, no tags.")).as_deref(), Some("Plain notes, no tags."));
|
||||||
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
|
assert_eq!(body(Some(cut), None).as_deref(), Some(cut), "a damaged body beats none");
|
||||||
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
|
assert_eq!(body(None, Some("Summary")).as_deref(), Some("Summary"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_subtitle_missing_from_the_body_is_kept_above_it() {
|
||||||
|
// Substack: the subtitle is the description, and content:encoded does not repeat it.
|
||||||
|
assert_eq!(
|
||||||
|
body(Some("<p>The post.</p>"), Some("Why the <b> tag & I fell out")).as_deref(),
|
||||||
|
Some("<p>The post.</p>"),
|
||||||
|
"a description with markup in it is notes, not a subtitle",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
body(Some("<p>The post.</p>"), Some("Why Q&A threads go wrong")).as_deref(),
|
||||||
|
Some("<p><em>Why Q&A threads go wrong</em></p><p>The post.</p>"),
|
||||||
|
);
|
||||||
|
// A podcast repeating its notes, whole, cut short, or differently spaced: shown once.
|
||||||
|
let notes = "<p>This week we talk about <a href=\"x\">tape</a>, drums and a very long list.</p>";
|
||||||
|
for d in ["This week we talk about tape, drums and a very long list.",
|
||||||
|
"This week we talk about tape, drums…",
|
||||||
|
"This week we talk about\ntape [...]"] {
|
||||||
|
assert_eq!(body(Some(notes), Some(d)).as_deref(), Some(notes), "{d:?} is already in the body");
|
||||||
|
}
|
||||||
|
let long = "word ".repeat(80);
|
||||||
|
assert_eq!(body(Some("<p>The post.</p>"), Some(&long)).as_deref(), Some("<p>The post.</p>"),
|
||||||
|
"a long description is notes, not a subtitle");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn feed_level_explicit_overrides_entries() {
|
fn feed_level_explicit_overrides_entries() {
|
||||||
let xml = br#"<?xml version="1.0"?>
|
let xml = br#"<?xml version="1.0"?>
|
||||||
|
|||||||
18
src/main.rs
18
src/main.rs
@@ -1,3 +1,4 @@
|
|||||||
|
mod access;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod config;
|
mod config;
|
||||||
mod db;
|
mod db;
|
||||||
@@ -550,11 +551,16 @@ async fn start_web(
|
|||||||
// The token is config.toml's, not the database's: it decides who gets in.
|
// The token is config.toml's, not the database's: it decides who gets in.
|
||||||
fresh.save_bootstrap(config_path)?;
|
fresh.save_bootstrap(config_path)?;
|
||||||
ctx.set_cfg(fresh.clone());
|
ctx.set_cfg(fresh.clone());
|
||||||
println!("web ui token generated. Open:\n http://{bind}/?token={}", fresh.web.token);
|
// The token signs in as the admin, and whatever reads this process's output (docker logs,
|
||||||
|
// for one) is wider than who reads config.toml. So say where it is, never what it is.
|
||||||
|
println!(
|
||||||
|
"web ui token generated and saved to {} as [web] token. Open http://{bind}/?token=<that token>",
|
||||||
|
config_path.display()
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
println!(
|
println!(
|
||||||
"web ui at http://{bind}/?token={}",
|
"web ui at http://{bind}/ (the sign-in token is [web] token in {})",
|
||||||
ctx.cfg().web.token
|
config_path.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,10 +568,16 @@ async fn start_web(
|
|||||||
tracing::warn!(bind, "web ui is reachable off this machine; the token is all that guards it");
|
tracing::warn!(bind, "web ui is reachable off this machine; the token is all that guards it");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let access = Arc::new(access::Keys::default());
|
||||||
|
if let Some((team, _)) = ctx.cfg().web.access() {
|
||||||
|
let (access, ctx, team) = (access.clone(), ctx.clone(), team.to_owned());
|
||||||
|
tokio::spawn(async move { access.prefetch(&ctx.client, &team).await });
|
||||||
|
}
|
||||||
let state = web::WebState {
|
let state = web::WebState {
|
||||||
ctx: ctx.clone(),
|
ctx: ctx.clone(),
|
||||||
cmds: cmds.clone(),
|
cmds: cmds.clone(),
|
||||||
events: events.clone(),
|
events: events.clone(),
|
||||||
|
access,
|
||||||
};
|
};
|
||||||
Ok(Some(tokio::spawn(async move {
|
Ok(Some(tokio::spawn(async move {
|
||||||
if let Err(e) = web::serve(state, &bind).await {
|
if let Err(e) = web::serve(state, &bind).await {
|
||||||
|
|||||||
39
src/web.rs
39
src/web.rs
@@ -29,6 +29,8 @@ pub struct WebState {
|
|||||||
pub ctx: Arc<Ctx>,
|
pub ctx: Arc<Ctx>,
|
||||||
pub cmds: mpsc::Sender<Command>,
|
pub cmds: mpsc::Sender<Command>,
|
||||||
pub events: broadcast::Sender<Event>,
|
pub events: broadcast::Sender<Event>,
|
||||||
|
/// Cloudflare Access's signing keys, fetched once and kept.
|
||||||
|
pub access: Arc<crate::access::Keys>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(state: WebState) -> Router {
|
pub fn router(state: WebState) -> Router {
|
||||||
@@ -99,7 +101,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
|||||||
let token = cfg.web.token.clone();
|
let token = cfg.web.token.clone();
|
||||||
|
|
||||||
// 1. A header, but only from a hop we were told to believe.
|
// 1. A header, but only from a hop we were told to believe.
|
||||||
let vouched = vouched_name(&cfg, &req);
|
let vouched = vouched_name(&state, &cfg, peer(&req), req.headers()).await;
|
||||||
|
|
||||||
let mut set_cookie: Option<String> = None;
|
let mut set_cookie: Option<String> = None;
|
||||||
let mut user = None;
|
let mut user = None;
|
||||||
@@ -201,20 +203,31 @@ struct Proxied(bool);
|
|||||||
|
|
||||||
/// The name the proxy vouches for, when this request came from one of `trusted_proxies` and
|
/// The name the proxy vouches for, when this request came from one of `trusted_proxies` and
|
||||||
/// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and
|
/// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and
|
||||||
/// be whoever they liked.
|
/// be whoever they liked. With `access_team` and `access_aud` set, it also has to carry a
|
||||||
fn vouched_name(cfg: &crate::config::Config, req: &Request) -> Option<String> {
|
/// token Cloudflare Access signed, and the name is the one in the token.
|
||||||
let peer = req
|
///
|
||||||
.extensions()
|
/// Takes the request's parts rather than the request: a `&Request` held across the await makes
|
||||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
/// the future unsendable, as a body is not `Sync`.
|
||||||
.map(|c| c.0.ip().to_string())
|
async fn vouched_name(state: &WebState, cfg: &crate::config::Config, peer: String, headers: &axum::http::HeaderMap) -> Option<String> {
|
||||||
.unwrap_or_default();
|
|
||||||
if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
|
if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
req.headers()
|
let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
|
||||||
.get(&cfg.web.trusted_header)
|
let Some((team, aud)) = cfg.web.access() else {
|
||||||
.and_then(|v| v.to_str().ok())
|
return header(&cfg.web.trusted_header).and_then(crate::auth::name_from_header);
|
||||||
.and_then(crate::auth::name_from_header)
|
};
|
||||||
|
// The plain header still has to be there, as it is what switches this path on for a
|
||||||
|
// request; who it names is the token's to say.
|
||||||
|
header(&cfg.web.trusted_header)?;
|
||||||
|
let token = header("Cf-Access-Jwt-Assertion")?;
|
||||||
|
state.access.verify(&state.ctx.client, team, aud, token).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peer(req: &Request) -> String {
|
||||||
|
req.extensions()
|
||||||
|
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||||
|
.map(|c| c.0.ip().to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
|
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
|
||||||
@@ -474,7 +487,7 @@ async fn remove_user(
|
|||||||
/// The password form, except for someone the proxy vouches for: they are signed in already, and
|
/// The password form, except for someone the proxy vouches for: they are signed in already, and
|
||||||
/// the form only made it look as if they were not.
|
/// the form only made it look as if they were not.
|
||||||
async fn login_page(State(state): State<WebState>, req: Request) -> Response {
|
async fn login_page(State(state): State<WebState>, req: Request) -> Response {
|
||||||
if vouched_name(&state.ctx.cfg(), &req).is_some() {
|
if vouched_name(&state, &state.ctx.cfg(), peer(&req), req.headers()).await.is_some() {
|
||||||
return Redirect::to("/").into_response();
|
return Redirect::to("/").into_response();
|
||||||
}
|
}
|
||||||
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))))
|
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))))
|
||||||
|
|||||||
BIN
tests/data/access-forger.der
Normal file
BIN
tests/data/access-forger.der
Normal file
Binary file not shown.
BIN
tests/data/access-test.der
Normal file
BIN
tests/data/access-test.der
Normal file
Binary file not shown.
12
tests/data/access-test.jwks.json
Normal file
12
tests/data/access-test.jwks.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"kid": "k1",
|
||||||
|
"kty": "RSA",
|
||||||
|
"alg": "RS256",
|
||||||
|
"use": "sig",
|
||||||
|
"e": "AQAB",
|
||||||
|
"n": "1T_jY4dGnU5YJonLMXdTyqFdV2J-67t5NTmTP1mf6kEYw_lW1xWB7306w8XOiplWD9cEDviKh6vQbmTTXL6-z8WnG-9YeRsPOOv0vb8txiuzJZ10ZQDBpbDdfidcESryl6ts7-ApsFz27B060wmHTwL4pywQw4wwmrubkiRwvpidzBpmDlkGZHdy3XV2TTfzQwwTtTuCR6Fd6D8lfK0XL6J5UC-RTH8_v9XEjF7DnI_bflB0olEwAqJ0-3E4xOj9okLOO5sfwE2SZk4yEMhFV4xqjtv8EN0KMT6BGIGs_VPDrSVtt23sEMsDOmeO6Pf9C6bkXy6faREpsX4einQykw"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user