v3 : points de la relecture indépendante — une personne une voix, ordre de déploiement, durcissements
- Dans Monnaie libre (électorat = toile de confiance), seuls les membres de la toile votent (trustin_access::votes_in) ; une identité d'invitation y lit, propose et signale. L'interface le dit à la place des boutons (fr/en/es). - Un lien d'invitation ne vise jamais une identité Ğ1 ; le lien est réservé avant de créer l'identité (deux acceptations simultanées : une seule passe). - Un nombre n'est jamais une coquille (« 50 » → « 5,0 », « 1 5 » → « 15 »). - Redirection après connexion : caractères de contrôle refusés. - En-têtes : X-Frame-Options DENY, frame-ancestors 'none', nosniff, Referrer-Policy. - Déploiement : le produit passe en premier ; la maquette attend son succès. Les conteneurs, même arrêtés, qui revendiquent encore la racine sortent de « restart: always » (ils reviendraient au redémarrage de Docker). - Tests : 57 Rust (dont 14 scénarios d'API), e2e 12 / 12 avec C11. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -129,8 +129,12 @@ pub async fn invite(
|
||||
if label.is_empty() || label.chars().count() > 60 {
|
||||
return Err(AppError::Invalid("label"));
|
||||
}
|
||||
if let Some(id) = &b.identity_id {
|
||||
auth::load_identity(&state, id).await?;
|
||||
// A new link for an existing person is for invitation identities only: a link
|
||||
// to a Ğ1 identity would let an administrator open a session in its name.
|
||||
if let Some(id) = &b.identity_id
|
||||
&& auth::load_identity(&state, id).await?.kind != "invite"
|
||||
{
|
||||
return Err(AppError::Invalid("identity_kind"));
|
||||
}
|
||||
let token =
|
||||
auth::create_invitation(&state, label, b.admin, &b.groups, Some(&user.id), b.identity_id.as_deref()).await?;
|
||||
|
||||
@@ -169,7 +169,16 @@ pub async fn me(Viewer(viewer): Viewer) -> Json<serde_json::Value> {
|
||||
/// Only local paths, never `//host` nor a scheme.
|
||||
fn safe_return(raw: Option<&str>) -> String {
|
||||
match raw {
|
||||
Some(p) if p.starts_with('/') && !p.starts_with("//") && !p.contains('\\') && p.len() < 512 => p.to_owned(),
|
||||
// Browsers drop tabs and newlines in URLs: "/\t/evil" would become "//evil".
|
||||
Some(p)
|
||||
if p.starts_with('/')
|
||||
&& !p.starts_with("//")
|
||||
&& !p.contains('\\')
|
||||
&& !p.chars().any(char::is_control)
|
||||
&& p.len() < 512 =>
|
||||
{
|
||||
p.to_owned()
|
||||
}
|
||||
_ => "/groupes".to_owned(),
|
||||
}
|
||||
}
|
||||
@@ -324,18 +333,33 @@ struct InvitationRow {
|
||||
|
||||
pub async fn accept_invitation(State(state): State<AppState>, Json(body): Json<InvitationBody>) -> AppResult<Response> {
|
||||
let hash = util::sha256_hex(body.token.trim());
|
||||
let now = util::now();
|
||||
// Reserve the link first: of two simultaneous acceptances, one only goes on.
|
||||
let reserved =
|
||||
sqlx::query("UPDATE invitations SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?")
|
||||
.bind(&now)
|
||||
.bind(&hash)
|
||||
.bind(&now)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if reserved.rows_affected() != 1 {
|
||||
return Err(AppError::Invalid("invitation_invalid"));
|
||||
}
|
||||
let inv = sqlx::query_as::<_, InvitationRow>(
|
||||
"SELECT token_hash, label, admin, groups, created_by, identity_id FROM invitations
|
||||
WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?",
|
||||
"SELECT token_hash, label, admin, groups, created_by, identity_id FROM invitations WHERE token_hash = ?",
|
||||
)
|
||||
.bind(&hash)
|
||||
.bind(util::now())
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::Invalid("invitation_invalid"))?;
|
||||
|
||||
let identity_id = match inv.identity_id.clone() {
|
||||
Some(existing) => existing,
|
||||
Some(existing) => {
|
||||
if load_identity(&state, &existing).await?.kind != "invite" {
|
||||
return Err(AppError::Invalid("invitation_invalid"));
|
||||
}
|
||||
existing
|
||||
}
|
||||
None => {
|
||||
let id = format!("inv:{}", util::id());
|
||||
sqlx::query(
|
||||
@@ -355,16 +379,11 @@ pub async fn accept_invitation(State(state): State<AppState>, Json(body): Json<I
|
||||
}
|
||||
let groups: Vec<String> = serde_json::from_str(&inv.groups).unwrap_or_default();
|
||||
grant_groups(&state, &identity_id, &groups, inv.created_by.as_deref()).await?;
|
||||
let used =
|
||||
sqlx::query("UPDATE invitations SET used_at = ?, identity_id = ? WHERE token_hash = ? AND used_at IS NULL")
|
||||
.bind(util::now())
|
||||
.bind(&identity_id)
|
||||
.bind(&inv.token_hash)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
if used.rows_affected() != 1 {
|
||||
return Err(AppError::Invalid("invitation_invalid"));
|
||||
}
|
||||
sqlx::query("UPDATE invitations SET identity_id = ? WHERE token_hash = ?")
|
||||
.bind(&identity_id)
|
||||
.bind(&inv.token_hash)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
let session = open_session(&state, &identity_id).await?;
|
||||
let me = load_identity(&state, &identity_id).await?;
|
||||
let mut res = Json(json!({ "me": me })).into_response();
|
||||
|
||||
@@ -69,6 +69,12 @@ pub async fn require_open(state: &AppState, user: &IdentityRow, group: &GroupRow
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// May this identity vote in the group (see `trustin_access::votes_in`)?
|
||||
pub async fn may_vote(state: &AppState, user: &IdentityRow, group: &GroupRow) -> AppResult<bool> {
|
||||
let has = granted(state, &group.slug, &user.id).await?;
|
||||
Ok(trustin_access::votes_in(&user.to_identity(), &group.to_group(), has))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GroupView {
|
||||
@@ -523,7 +529,9 @@ pub async fn document(
|
||||
.collect();
|
||||
|
||||
let publication = publication_status(&state, &id).await?;
|
||||
let can_vote = may_vote(&state, &user, &st.group).await?;
|
||||
Ok(Json(json!({
|
||||
"canVote": can_vote,
|
||||
"doc": {
|
||||
"id": st.doc.id, "slug": st.doc.slug, "groupSlug": st.group.slug, "groupName": st.group.name,
|
||||
"title": util::parse_lang(&st.doc.title), "summary": util::parse_lang(&st.doc.summary),
|
||||
|
||||
@@ -123,9 +123,20 @@ pub fn api_router() -> Router<AppState> {
|
||||
}
|
||||
|
||||
pub fn app(state: AppState) -> Router {
|
||||
Router::new()
|
||||
.nest("/api", api_router())
|
||||
.fallback_service(web::service(&state.cfg.web_dir))
|
||||
use axum::http::{HeaderName, HeaderValue, header};
|
||||
use tower_http::set_header::SetResponseHeaderLayer;
|
||||
// Neighbouring sites under *.librodrome.org are same-site: none may frame us.
|
||||
let security: [(HeaderName, &'static str); 4] = [
|
||||
(header::X_FRAME_OPTIONS, "DENY"),
|
||||
(header::CONTENT_SECURITY_POLICY, "frame-ancestors 'none'"),
|
||||
(header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
|
||||
(header::REFERRER_POLICY, "same-origin"),
|
||||
];
|
||||
let mut router = Router::new().nest("/api", api_router()).fallback_service(web::service(&state.cfg.web_dir));
|
||||
for (name, value) in security {
|
||||
router = router.layer(SetResponseHeaderLayer::if_not_present(name, HeaderValue::from_static(value)));
|
||||
}
|
||||
router
|
||||
.layer(tower_http::limit::RequestBodyLimitLayer::new(256 * 1024))
|
||||
.layer(tower_http::compression::CompressionLayer::new())
|
||||
.layer(tower_http::trace::TraceLayer::new_for_http())
|
||||
|
||||
@@ -594,6 +594,10 @@ pub async fn vote(
|
||||
if !matches!(p.status.as_str(), "open" | "in_force") {
|
||||
return Err(AppError::Conflict("not_votable"));
|
||||
}
|
||||
// Withdrawing one's vote is always possible; casting one follows trustinWallet.
|
||||
if b.choice.is_some() && !docs::may_vote(&state, &user, &st.group).await? {
|
||||
return Err(AppError::Conflict("wot_members_only"));
|
||||
}
|
||||
match b.choice.as_deref() {
|
||||
Some(choice @ ("for" | "against")) => {
|
||||
sqlx::query(
|
||||
|
||||
@@ -79,6 +79,9 @@ async fn anonymous_sees_nothing_and_csrf_is_enforced() {
|
||||
.expect("request");
|
||||
let res = c.router.clone().oneshot(req).await.expect("response");
|
||||
assert_eq!(res.status(), StatusCode::FORBIDDEN);
|
||||
// No neighbour may frame the product.
|
||||
assert_eq!(res.headers().get(header::X_FRAME_OPTIONS).map(|v| v.as_bytes()), Some(&b"DENY"[..]));
|
||||
assert!(res.headers().contains_key(header::X_CONTENT_TYPE_OPTIONS));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -377,6 +380,38 @@ async fn access_management_is_for_administrators_only() {
|
||||
assert!(ov["identities"].as_array().expect("i").iter().any(|i| i["displayName"] == "Chiara"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn in_monnaie_libre_only_web_of_trust_members_vote() {
|
||||
let (mut admin, _) = Client::new().await;
|
||||
admin.login("Yvv", true).await;
|
||||
let (_, v) = admin
|
||||
.call("POST", "/api/access/invitations", Some(json!({ "label": "Invitée", "groups": ["monnaie-libre"] })))
|
||||
.await;
|
||||
let token = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned();
|
||||
let mut invited = admin.with(None);
|
||||
let (s, _) = invited.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
// She reads the text and may propose, but her vote would not be a web-of-trust vote.
|
||||
let id = doc_id(&mut invited, "monnaie-libre").await;
|
||||
let (s, doc) = invited.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
assert_eq!(doc["canVote"], false);
|
||||
let p = unit(&doc, "E3")["proposals"][0]["id"].as_str().expect("p").to_owned();
|
||||
let (s, v) = invited.call("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
||||
assert_eq!(s, StatusCode::CONFLICT, "{v}");
|
||||
assert_eq!(v["error"], "wot_members_only");
|
||||
// In the test group, every member votes.
|
||||
let essai = doc_id(&mut admin, "essai").await;
|
||||
let (_, d) = admin.call("GET", &format!("/api/docs/{essai}"), None).await;
|
||||
assert_eq!(d["canVote"], true);
|
||||
// A new link may only target an invitation identity, never another kind.
|
||||
let (_, me) = admin.call("GET", "/api/me", None).await;
|
||||
let dev_id = me["me"]["id"].as_str().expect("id").to_owned();
|
||||
let (s, _) =
|
||||
admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Yvv", "identityId": dev_id }))).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn feedback_reaches_administrators() {
|
||||
let (mut user, _) = Client::new().await;
|
||||
|
||||
Reference in New Issue
Block a user