3dd6582d Andy McCall 20 days ago ↶
1
2
3
4
5
6
7
use super :: settings:: csrf;
8
use super :: { Error, Page, Shell} ;
9
use crate :: auth:: Viewer;
10
use crate :: db:: { self , accounts} ;
11
use crate :: Shared;
12
use axum:: extract:: { Form, State} ;
13
use axum:: http:: { header, StatusCode} ;
14
use axum:: response:: { IntoResponse, Response} ;
15
use serde:: Deserialize;
16
17
const CONTACT : & str = " security_contact" ;
18
const POLICY : & str = " security_policy" ;
2cbf6f34 Andy McCall 19 days ago ↶
19
const EMAIL : & str = " security_email" ;
20
const PAGE : & str = " security_page" ;
21
22
const MAX_PAGE : usize = 20_000 ;
23
24
25
26
pub const DEFAULT_PAGE : & str = " If you think you have found a security problem in this hub, please tell us privately rather than in a public issue, proposal or comment.\n \n \
27
Please include:\n \n \
28
- what you found, and where (the page, the API call, the repository if one is involved);\n \
29
- how to reproduce it, step by step;\n \
30
- what you think someone could do with it.\n \n \
31
While you look, please do not read or change other people's data beyond what you need to show the problem, do not run tests that slow the service down for others, and give us a fair chance to fix it before you tell anyone else.\n " ;
3dd6582d Andy McCall 20 days ago ↶
32
33
const EXPIRES_MS : i64 = 360 * 24 * 60 * 60_000 ;
2cbf6f34 Andy McCall 19 days ago ↶
34
35
36
pub async fn page_settings ( app : & Shared) -> ( String, String) {
37
let get = | k | async move { accounts:: setting( & app.db, k) .await.ok ( ) .flatten ( ) .unwrap_or_default ( ) } ;
38
( get ( EMAIL ) .await, get ( PAGE ) .await)
39
}
40
41
# [ derive ( askama::Template ) ]
42
# [ template ( path = " security_contact.html" ) ]
43
pub struct ContactPage {
44
shell : Shell,
45
email : String,
46
47
body_html : String,
48
}
3dd6582d Andy McCall 20 days ago ↶
49
2cbf6f34 Andy McCall 19 days ago ↶
50
51
pub async fn contact_page ( State( app ) : State< Shared> , viewer : Viewer) -> Page< ContactPage> {
52
let shell = Shell:: new( & app, " /security-contact" , & viewer) ;
f59e9680 Andy McCall 19 days ago ↶
53
let ( mut email, text) = page_settings ( & app) .await;
54
55
if email.is_empty ( ) {
56
let ( contact, _ ) = settings ( & app) .await;
57
email = contact_uri ( & contact) .and_then ( | u | u.strip_prefix ( " mailto:" ) .map ( String :: from) ) .unwrap_or_default ( ) ;
58
}
2cbf6f34 Andy McCall 19 days ago ↶
59
let text = if text.trim ( ) .is_empty ( ) { DEFAULT_PAGE .to_string ( ) } else { text } ;
60
Page( ContactPage { shell, email, body_html: crate :: render:: markdown:: render( & text, " " , " " ) } )
61
}
62
3dd6582d Andy McCall 20 days ago ↶
63
64
pub async fn settings ( app : & Shared) -> ( String, String) {
65
let get = | k | async move { accounts:: setting( & app.db, k) .await.ok ( ) .flatten ( ) .unwrap_or_default ( ) } ;
66
( get ( CONTACT ) .await, get ( POLICY ) .await)
67
}
68
69
70
71
pub fn contact_uri ( contact : & str ) -> Option< String> {
72
let c = contact.trim ( ) ;
73
if c.is_empty ( ) | | c.chars ( ) .any ( | ch | ch.is_whitespace ( ) | | ch.is_control ( ) ) {
74
return None ;
75
}
76
if c.starts_with ( " https://" ) | | c.starts_with ( " mailto:" ) {
77
return Some ( c.to_string ( ) ) ;
78
}
79
let ( user, domain) = c.split_once ( ' @' ) ? ;
80
( ! user.is_empty ( ) & & domain.contains ( ' .' ) & & ! domain.contains ( ' @' ) & & ! c.contains ( ' :' ) ) .then ( | | format! ( " mailto:{c} " ) )
81
}
82
83
84
pub fn text ( base_url : & str , contact : & str , policy : & str , now_ms : i64 ) -> Option< String> {
85
let contact = contact_uri ( contact) ? ;
86
let expires = crate :: render:: time:: rfc3339( ( now_ms + EXPIRES_MS ) as u64 ) ;
87
let base = base_url.trim_end_matches ( ' /' ) ;
88
let mut out = format! ( " Contact: {contact} \n Expires: {expires} \n " ) ;
89
if ! policy.trim ( ) .is_empty ( ) {
90
out.push_str ( & format! ( " Policy: {} \n " , policy.trim ( ) ) ) ;
91
}
92
out.push_str ( & format! ( " Preferred-Languages: en\n Canonical: {base} /.well-known/security.txt\n " ) ) ;
93
Some ( out)
94
}
95
96
97
pub async fn serve ( State( app ) : State< Shared> ) -> Response {
98
let ( contact, policy) = settings ( & app) .await;
99
match text ( app.config.base_url ( ) , & contact, & policy, db:: now_ms( ) ) {
100
Some ( t) => ( [ ( header:: CONTENT_TYPE , " text/plain; charset=utf-8" ) ] , t) .into_response ( ) ,
101
None => ( StatusCode:: NOT_FOUND , " This hub has no security contact set.\n " ) .into_response ( ) ,
102
}
103
}
104
105
# [ derive ( Deserialize ) ]
106
pub struct SecurityForm {
107
#[serde(rename = "_csrf")]
108
csrf_token : String,
109
#[serde(default)]
110
contact : String,
111
#[serde(default)]
112
policy : String,
2cbf6f34 Andy McCall 19 days ago ↶
113
114
#[serde(default)]
115
email : String,
116
117
#[serde(default)]
118
page : String,
3dd6582d Andy McCall 20 days ago ↶
119
}
120
121
122
123
pub async fn save ( State( app ) : State< Shared> , viewer : Viewer, Form( f ) : Form< SecurityForm> ) -> Result< Page< super:: admin:: AdminPage> , Error> {
124
let shell = Shell:: new( & app, " /admin" , & viewer) ;
125
let me = super :: admin:: require_admin( & viewer, & shell) ? ;
126
csrf ( & viewer, & f.csrf_token, & shell) ? ;
127
let ( contact, policy) = ( f.contact.trim ( ) , f.policy.trim ( ) ) ;
128
if ! contact.is_empty ( ) & & contact_uri ( contact) .is_none ( ) {
129
return Err ( Error:: bad( shell, " The contact is an email address or an https:// page." ) ) ;
130
}
131
if ! policy.is_empty ( ) & & ( ! policy.starts_with ( " https://" ) | | policy.chars ( ) .any ( char :: is_whitespace) ) {
132
return Err ( Error:: bad( shell, " The policy is an https:// page, or empty." ) ) ;
2cbf6f34 Andy McCall 19 days ago ↶
133
}
134
let ( email, page) = ( f.email.trim ( ) , f.page.trim ( ) ) ;
135
if ! email.is_empty ( ) & & contact_uri ( email) .is_none_or ( | u | ! u.starts_with ( " mailto:" ) ) {
136
return Err ( Error:: bad( shell, " The reporting address is an email address, or empty." ) ) ;
137
}
138
if page.chars ( ) .count ( ) > MAX_PAGE {
139
return Err ( Error:: bad( shell, format! ( " The page text is at most {MAX_PAGE} characters." ) ) ) ;
3dd6582d Andy McCall 20 days ago ↶
140
}
141
let e = | e | Error:: from_db( shell.clone ( ) , e) ;
142
accounts:: set_setting( & app.db, CONTACT , contact) .await.map_err ( e) ? ;
143
accounts:: set_setting( & app.db, POLICY , policy) .await.map_err ( e) ? ;
2cbf6f34 Andy McCall 19 days ago ↶
144
accounts:: set_setting( & app.db, EMAIL , email) .await.map_err ( e) ? ;
145
accounts:: set_setting( & app.db, PAGE , page) .await.map_err ( e) ? ;
3dd6582d Andy McCall 20 days ago ↶
146
accounts:: audit( & app.db, Some ( & me) , " admin.security_contact" , contact, policy) .await.ok ( ) ;
147
let notice = if contact.is_empty ( ) {
148
" No security contact: /.well-known/security.txt is gone." .to_string ( )
149
} else {
150
" Security contact saved: /.well-known/security.txt names it." .to_string ( )
151
} ;
737de0fa Andy McCall 15 days ago ↶
152
super :: admin:: admin_page( & app, shell, super :: admin:: AdminSection:: SecurityContact, Some ( notice) ) .await
3dd6582d Andy McCall 20 days ago ↶
153
}
154
155
# [ cfg ( test ) ]
156
mod tests {
157
use super :: * ;
158
159
# [ test ]
160
fn contacts_become_uris_or_are_refused ( ) {
161
assert_eq! ( contact_uri ( " [email protected] " ) .as_deref ( ) , Some ( " mailto:[email protected] " ) ) ;
162
assert_eq! ( contact_uri ( " https://example.org/report " ) .as_deref ( ) , Some ( " https://example.org/report" ) ) ;
163
assert_eq! ( contact_uri ( " mailto:[email protected] " ) .as_deref ( ) , Some ( " mailto:[email protected] " ) ) ;
164
for bad in [ " " , " http://example.org" , " nobody" , " a@b" , " a [email protected] " , " javascript:alert(1)" , " [email protected] \n Contact: evil" ] {
165
assert_eq! ( contact_uri ( bad) , None , " {bad:?}" ) ;
166
}
167
}
168
169
# [ test ]
170
fn the_file_expires_under_a_year_ahead_and_is_canonical ( ) {
171
let now = 1_800_000_000_000 ;
172
let t = text ( " https://hub.example/" , " [email protected] " , " https://hub.example/policy" , now) .unwrap ( ) ;
173
assert_eq! (
174
t,
175
" Contact: mailto:[email protected] \n Expires: 2028-01-10T08:00:00Z\n Policy: https://hub.example/policy\n \
176
Preferred-Languages: en\n Canonical: https://hub.example/.well-known/security.txt\n "
177
) ;
178
assert! ( ! text ( " https://hub.example" , " [email protected] " , " " , now) .unwrap ( ) .contains ( " Policy" ) ) ;
179
assert! ( text ( " https://hub.example" , " " , " " , now) .is_none ( ) ) ;
180
}
181
}