Skip to main content

pharos_runtime_static/
error_page.rs

1//! Safe HTML presentation of HTTP errors for full browser navigations.
2
3use pharos_runtime_core::http::HttpRequest;
4
5/// Presents plain/JSON failures as a browser page without changing API or fragment contracts.
6pub 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('&', "&amp;"),
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
116/// Keeps browser error recovery in the selected preview using only validated task and app identifiers.
117fn 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    /// Preserves preview recovery scope and never reflects untrusted identifiers into HTML.
156    #[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&amp;app_id=architect_edit&amp;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    /// Keeps machine responses intact and prevents sensitive error details reaching browser pages.
183    #[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    /// Certifies every shared copy entry and common browser error status renders valid HTML.
210    #[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}