pharos_runtime_static/
error_page.rs1use pharos_runtime_core::http::HttpRequest;
4
5pub fn browser_response(request: &HttpRequest, response: Vec<u8>) -> Vec<u8> {
7 let path = request.target.split('?').next().unwrap_or_default();
8 let html = request.headers.lines().any(|line| {
9 line.split_once(':').is_some_and(|(name, value)| {
10 name.eq_ignore_ascii_case("accept")
11 && value.split(',').any(|part| {
12 let mut fields = part.trim().split(';');
13 fields.next() == Some("text/html") && !fields.any(|field| field.trim() == "q=0")
14 })
15 })
16 });
17 let fragment = request.headers.lines().any(|line| {
18 line.split_once(':').is_some_and(|(name, value)| {
19 name.eq_ignore_ascii_case("hx-request") && value.trim().eq_ignore_ascii_case("true")
20 })
21 });
22 if !html
23 || fragment
24 || path
25 .split('/')
26 .any(|part| matches!(part, "api" | "rpc" | "webhook" | "webhooks"))
27 {
28 return response;
29 }
30 let Some(end) = response.windows(4).position(|part| part == b"\r\n\r\n") else {
31 return response;
32 };
33 if end > 64 * 1024 {
34 return response;
35 }
36 let Ok(head) = std::str::from_utf8(&response[..end]) else {
37 return response;
38 };
39 let Some(status) = head
40 .lines()
41 .next()
42 .and_then(|line| line.split_whitespace().nth(1))
43 .and_then(|value| value.parse::<u16>().ok())
44 else {
45 return response;
46 };
47 if !(400..600).contains(&status)
48 || head.lines().any(|line| {
49 line.split_once(':').is_some_and(|(name, value)| {
50 name.eq_ignore_ascii_case("content-type") && value.trim().starts_with("text/html")
51 })
52 })
53 {
54 return response;
55 }
56 let key = match status {
57 400 | 413 | 415 | 422 => "invalid",
58 401 => "unauthorized",
59 403 => "forbidden",
60 404 | 410 => "missing",
61 405 => "method",
62 409 => "conflict",
63 429 => "limited",
64 502..=504 => "unavailable",
65 _ => "internal",
66 };
67 static COPY: std::sync::OnceLock<Option<serde_json::Value>> = std::sync::OnceLock::new();
68 let copy = COPY.get_or_init(|| {
69 serde_json::from_str(include_str!("../templates/http_error_copy.json")).ok()
70 });
71 let Some(copy) = copy.as_ref().and_then(|copy| copy.get(key)) else {
72 return response;
73 };
74 let Some(title) = copy.get("title").and_then(serde_json::Value::as_str) else {
75 return response;
76 };
77 let Some(message) = copy.get("message").and_then(serde_json::Value::as_str) else {
78 return response;
79 };
80 let body = include_str!("../templates/http_error.html")
81 .replace("{{status}}", &status.to_string())
82 .replace("{{title}}", title)
83 .replace("{{message}}", message)
84 .replace(
85 "{{return_url}}",
86 &preview_error_return_url(request).replace('&', "&"),
87 );
88 let mut output = String::new();
89 for (index, line) in head.split("\r\n").enumerate() {
90 if index == 0
91 || !line.split_once(':').is_some_and(|(name, _)| {
92 [
93 "content-type",
94 "content-length",
95 "content-encoding",
96 "transfer-encoding",
97 "cache-control",
98 "content-security-policy",
99 "x-content-type-options",
100 ]
101 .iter()
102 .any(|excluded| name.eq_ignore_ascii_case(excluded))
103 })
104 {
105 output.push_str(line);
106 output.push_str("\r\n");
107 }
108 }
109 output.push_str(&format!("Content-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nContent-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'\r\n\r\n",body.len()));
110 if request.method != "HEAD" {
111 output.push_str(&body);
112 }
113 output.into_bytes()
114}
115
116fn preview_error_return_url(request: &HttpRequest) -> String {
118 let Some((path, query)) = request.target.split_once('?') else {
119 return "/".to_owned();
120 };
121 if !matches!(path, "/architect/app/edit" | "/architect/apps/view") {
122 return "/".to_owned();
123 }
124 let value = |key: &str| {
125 query.split('&').find_map(|part| {
126 let (name, value) = part.split_once('=')?;
127 (name == key).then_some(value)
128 })
129 };
130 let Some(task_id) = value("task_id")
131 .and_then(|value| value.parse::<i64>().ok())
132 .filter(|id| *id > 0)
133 else {
134 return "/".to_owned();
135 };
136 let app = value("app_id").filter(|value| {
137 !value.is_empty()
138 && value.len() <= 64
139 && value
140 .bytes()
141 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
142 });
143 let mut target = format!("/architect/app/edit?task_id={task_id}");
144 if let Some(app) = app {
145 target.push_str(&format!("&app_id={app}"));
146 }
147 target.push_str("&path=%2F");
148 target
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
157 fn preview_browser_errors_return_to_the_editor() {
158 let request = HttpRequest {
159 target: "/architect/app/edit?task_id=123&app_id=architect_edit&path=%2Fmissing".into(),
160 headers: "Accept: text/html\r\n".into(),
161 method: "GET".into(),
162 ..Default::default()
163 };
164 let response =
165 b"HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\n\r\nsecret"
166 .to_vec();
167 let rendered = String::from_utf8(browser_response(&request, response)).unwrap();
168 assert!(rendered.contains(
169 "href=\"/architect/app/edit?task_id=123&app_id=architect_edit&path=%2F\""
170 ));
171 assert!(!rendered.contains("secret"));
172 let mut unsafe_request = request;
173 unsafe_request.target = "/architect/apps/view?task_id=123&app_id=\"onclick=evil".into();
174 assert_eq!(
175 preview_error_return_url(&unsafe_request),
176 "/architect/app/edit?task_id=123&path=%2F"
177 );
178 unsafe_request.target = "/architect/app/edit?task_id=-1&app_id=architect_edit".into();
179 assert_eq!(preview_error_return_url(&unsafe_request), "/");
180 }
181
182 #[test]
184 fn negotiates_browser_errors_without_exposing_details() {
185 let original = b"HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nContent-Length: 6\r\nSet-Cookie: session=kept\r\n\r\nsecret".to_vec();
186 let mut request = HttpRequest {
187 method: "GET".into(),
188 target: "/missing".into(),
189 headers: "Accept: text/html\r\n".into(),
190 ..Default::default()
191 };
192 let rendered = String::from_utf8(browser_response(&request, original.clone())).unwrap();
193 assert!(rendered.starts_with("HTTP/1.1 404"));
194 assert!(rendered.contains("Page not found"));
195 assert!(rendered.contains("Set-Cookie: session=kept"));
196 assert!(!rendered.contains("secret"));
197 request.headers = "Accept: application/json\r\n".into();
198 assert_eq!(browser_response(&request, original.clone()), original);
199 request.headers = "Accept: text/html\r\nHX-Request: true\r\n".into();
200 assert_eq!(browser_response(&request, original.clone()), original);
201 request.headers = "Accept: text/html\r\n".into();
202 request.target = "/app/api/missing".into();
203 assert_eq!(browser_response(&request, original.clone()), original);
204 request.target = "/missing".into();
205 request.method = "HEAD".into();
206 assert!(browser_response(&request, original).ends_with(b"\r\n\r\n"));
207 }
208
209 #[test]
211 fn covers_common_browser_failures() {
212 let request = HttpRequest {
213 method: "GET".into(),
214 target: "/missing".into(),
215 headers: "Accept: text/html\r\n".into(),
216 ..Default::default()
217 };
218 for status in [
219 400, 401, 403, 404, 405, 409, 410, 413, 415, 422, 429, 500, 502, 503, 504,
220 ] {
221 let response = format!(
222 "HTTP/1.1 {status} Error\r\nContent-Type: application/json\r\nRetry-After: 30\r\nContent-Length: 2\r\n\r\n{{}}"
223 );
224 let page =
225 String::from_utf8(browser_response(&request, response.into_bytes())).unwrap();
226 assert!(page.contains("Content-Type: text/html; charset=utf-8\r\n"));
227 assert!(page.contains("Retry-After: 30\r\n"));
228 assert!(page.contains("<!doctype html>"));
229 assert!(!page.contains("{{title}}"));
230 }
231 }
232}