Skip to main content

tuwunel_api/client/account/3pid/
email_validate.rs

1use std::net::IpAddr;
2
3use axum::{
4	extract::{Form, Request, State},
5	response::{Html, IntoResponse, Response},
6};
7use const_str::format as const_format;
8use http::{
9	StatusCode,
10	header::{CACHE_CONTROL, CONTENT_SECURITY_POLICY, REFERRER_POLICY},
11};
12use serde::Deserialize;
13use tuwunel_core::utils::html::escape as html_escape;
14
15use crate::ClientIp;
16
17// Per-response CSP: the form posts back and the page pulls the shared
18// stylesheet, both same-origin, which the global policy forbids.
19static VALIDATE_CSP: &str = "default-src 'none'; style-src 'self'; form-action 'self'; \
20                             frame-ancestors 'none'; base-uri 'none';";
21
22static VALIDATE_HEAD: &str = r#"
23	<meta charset="UTF-8">
24	<link rel="stylesheet" href="/_tuwunel/oidc/account.css">
25"#;
26
27static GENERIC_FAILURE: &str =
28	"This verification link is invalid or has expired. Request a new one from your client.";
29
30#[derive(Debug, Default, Deserialize)]
31pub(crate) struct ValidateParams {
32	sid: Option<String>,
33	client_secret: Option<String>,
34	token: Option<String>,
35}
36
37/// # `GET /_tuwunel/3pid/email/validate`
38///
39/// The magic-link target. Renders a confirmation page whose form posts the same
40/// parameters back; the token is never consumed on this request, so an email
41/// scanner that prefetches the link cannot spend it.
42pub(crate) async fn get_email_validate_route(
43	State(services): State<crate::State>,
44	ClientIp(client): ClientIp,
45	request: Request,
46) -> Response {
47	if let Some(limited) = rate_limited(services, client) {
48		return limited;
49	}
50
51	let params: ValidateParams =
52		serde_html_form::from_str(request.uri().query().unwrap_or_default()).unwrap_or_default();
53
54	validate_html(StatusCode::OK, confirm_html(&params))
55}
56
57/// # `POST /_tuwunel/3pid/email/validate`
58///
59/// Confirms the validation. A wrong or expired session renders the same failure
60/// page as any other error, so the page never reveals whether a session or
61/// token is live.
62pub(crate) async fn post_email_validate_route(
63	State(services): State<crate::State>,
64	ClientIp(client): ClientIp,
65	Form(params): Form<ValidateParams>,
66) -> Response {
67	if let Some(limited) = rate_limited(services, client) {
68		return limited;
69	}
70
71	let (Some(sid), Some(client_secret), Some(token)) =
72		(&params.sid, &params.client_secret, &params.token)
73	else {
74		return validate_html(StatusCode::OK, error_html(GENERIC_FAILURE));
75	};
76
77	match services
78		.threepid
79		.validate_pending_token(sid, client_secret, token)
80		.await
81	{
82		| Ok(()) => validate_html(
83			StatusCode::OK,
84			result_html(
85				"Email verified",
86				"Your email address has been verified. Return to your client to continue.",
87			),
88		),
89		| Err(_) => validate_html(StatusCode::OK, error_html(GENERIC_FAILURE)),
90	}
91}
92
93fn rate_limited(services: crate::State, client: IpAddr) -> Option<Response> {
94	services
95		.threepid
96		.check_ip_rate_limit(client)
97		.is_err()
98		.then(|| {
99			validate_html(
100				StatusCode::TOO_MANY_REQUESTS,
101				error_html("Too many requests. Please wait and try again."),
102			)
103		})
104}
105
106fn confirm_html(params: &ValidateParams) -> String {
107	let escape = |value: &Option<String>| html_escape(value.as_deref().unwrap_or_default());
108
109	// Token first: a later replace must not refill an injected {token}.
110	CONFIRM_HTML
111		.replace("{token}", &escape(&params.token))
112		.replace("{client_secret}", &escape(&params.client_secret))
113		.replace("{sid}", &escape(&params.sid))
114}
115
116static CONFIRM_HTML: &str = const_format!(
117	r#"
118<!DOCTYPE html>
119<html lang="en">
120	<head>
121		{VALIDATE_HEAD}
122		<title>Verify your email address</title>
123	</head>
124	<body>
125		<h1>Verify your email address</h1>
126		<p>Confirm that you want to verify this email address.</p>
127		<form method="POST" action="/_tuwunel/3pid/email/validate">
128			<input type="hidden" name="sid" value="{{sid}}">
129			<input type="hidden" name="client_secret" value="{{client_secret}}">
130			<input type="hidden" name="token" value="{{token}}">
131			<button type="submit" class="primary">Verify</button>
132		</form>
133	</body>
134</html>"#
135);
136
137fn result_html(title: &str, message: &str) -> String {
138	RESULT_HTML
139		.replace("{title}", &html_escape(title))
140		.replace("{message}", &html_escape(message))
141}
142
143static RESULT_HTML: &str = const_format!(
144	r#"
145<!DOCTYPE html>
146<html lang="en">
147	<head>
148		{VALIDATE_HEAD}
149		<title>{{title}}</title>
150	</head>
151	<body>
152		<h1>{{title}}</h1>
153		<p>{{message}}</p>
154	</body>
155</html>"#
156);
157
158fn error_html(message: &str) -> String { ERROR_HTML.replace("{msg}", &html_escape(message)) }
159
160static ERROR_HTML: &str = const_format!(
161	r#"
162<!DOCTYPE html>
163<html lang="en">
164	<head>
165		{VALIDATE_HEAD}
166		<title>Verification failed</title>
167	</head>
168	<body>
169		<h1 class="err">Verification failed</h1>
170		<p>{{msg}}</p>
171	</body>
172</html>"#
173);
174
175fn validate_html(status: StatusCode, html: String) -> Response {
176	let headers = [
177		(CACHE_CONTROL, "no-store"),
178		(CONTENT_SECURITY_POLICY, VALIDATE_CSP),
179		(REFERRER_POLICY, "no-referrer"),
180	];
181
182	(status, headers, Html(html)).into_response()
183}