zuka
zuka/src/web/html.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/web/html.rs
RShtml.rs19.0 KBDownload
1// HTML construction: escaping, the page shell, and the stylesheet.
2//
3// Everything user-controlled — repository names, paths, commit messages, file
4// contents — reaches a browser through this module, so escaping is centralised
5// here. There is no template engine: a template language would be a second syntax
6// to audit for the one thing that matters, which is that nothing interpolated is
7// ever trusted.
8
9use crate::brand;
10
11/// Escape text for HTML body or attribute context.
12///
13/// Handles both contexts, including quotes, so a caller cannot pick the wrong one.
14/// Single quotes are escaped too, because an attribute written with single quotes is
15/// otherwise an injection point and this function has no way to know which the
16/// caller used.
17pub fn escape(value: &str) -> String {
18 let mut out = String::with_capacity(value.len());
19 for c in value.chars() {
20 match c {
21 '&' => out.push_str("&"),
22 '<' => out.push_str("&lt;"),
23 '>' => out.push_str("&gt;"),
24 '"' => out.push_str("&quot;"),
25 '\'' => out.push_str("&#39;"),
26 _ => out.push(c),
27 }
28 }
29 out
30}
31
32/// Percent-encode a path segment for use in a URL.
33///
34/// Repository paths carry characters — `#`, `?`, spaces — that silently truncate or
35/// redirect a link if they reach an href raw.
36pub fn url_escape(value: &str) -> String {
37 let mut out = String::with_capacity(value.len());
38 for byte in value.as_bytes() {
39 match byte {
40 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
41 out.push(*byte as char)
42 }
43 _ => out.push_str(&format!("%{byte:02X}")),
44 }
45 }
46 out
47}
48
49/// Render markdown to HTML with raw HTML dropped.
50///
51/// A README is attacker-controlled on any public host: anyone who can push can put
52/// a `<script>` in it. pulldown-cmark passes raw HTML through by default, so the
53/// tags are filtered out here rather than escaped — escaping would show the markup
54/// as literal text, which is noise, while dropping it renders the readable content
55/// and none of the payload.
56pub fn markdown(source: &str) -> String {
57 use pulldown_cmark::{html, Event, Options, Parser, Tag};
58
59 let mut options = Options::empty();
60 options.insert(Options::ENABLE_TABLES);
61 options.insert(Options::ENABLE_STRIKETHROUGH);
62 options.insert(Options::ENABLE_TASKLISTS);
63 options.insert(Options::ENABLE_FOOTNOTES);
64
65 let parser = Parser::new_ext(source, options)
66 .filter(|event| {
67 !matches!(
68 event,
69 Event::Html(_) | Event::InlineHtml(_) | Event::FootnoteReference(_)
70 )
71 })
72 .map(|event| match event {
73 // pulldown-cmark emits link and image destinations verbatim, so
74 // `[x](javascript:…)` in a README becomes an executable href. Dropping
75 // the tags is not enough; the URLs have to be checked too.
76 Event::Start(Tag::Link {
77 link_type,
78 dest_url,
79 title,
80 id,
81 }) => Event::Start(Tag::Link {
82 link_type,
83 dest_url: safe_url(&dest_url).into(),
84 title,
85 id,
86 }),
87 Event::Start(Tag::Image {
88 link_type,
89 dest_url,
90 title,
91 id,
92 }) => Event::Start(Tag::Image {
93 link_type,
94 dest_url: safe_url(&dest_url).into(),
95 title,
96 id,
97 }),
98 other => other,
99 });
100
101 let mut out = String::new();
102 html::push_html(&mut out, parser);
103 out
104}
105
106/// Replace a URL that would execute with one that does nothing.
107///
108/// An allow-list of schemes, not a block-list: `javascript:` is the obvious case but
109/// `data:` can carry HTML and `vbscript:` still runs in some engines, and a
110/// block-list is a list of the attacks somebody already thought of. Anything without
111/// a scheme is a relative link and is left alone.
112fn safe_url(url: &str) -> String {
113 // Control characters and whitespace are stripped first: `java\tscript:` and
114 // `java\nscript:` are both parsed as the scheme by browsers but defeat a naive
115 // prefix comparison.
116 let cleaned: String = url
117 .chars()
118 .filter(|c| !c.is_whitespace() && !c.is_control())
119 .collect();
120
121 let Some((scheme, _)) = cleaned.split_once(':') else {
122 // No scheme at all — a relative link such as `docs/x.md` or `#anchor`.
123 return url.to_string();
124 };
125
126 // A `:` appearing after a `/` or `?` is part of a path, not a scheme.
127 if scheme.contains('/') || scheme.contains('?') || scheme.contains('#') {
128 return url.to_string();
129 }
130
131 const ALLOWED: [&str; 4] = ["http", "https", "mailto", "ftp"];
132 if ALLOWED.contains(&scheme.to_ascii_lowercase().as_str()) {
133 url.to_string()
134 } else {
135 // Rendered, but inert. Removing the link entirely would silently change what
136 // the document says.
137 "#blocked".to_string()
138 }
139}
140
141/// URL of the stylesheet, carrying the build so a deploy cannot serve stale CSS
142/// from a cache told to keep it for a year.
143pub fn style_url() -> String {
144 format!("/_{}/{}.css", brand::NAME, brand::COMMIT)
145}
146
147pub struct Page {
148 pub title: String,
149 /// Rendered into the header bar, already escaped.
150 pub heading: String,
151 pub body: String,
152}
153
154/// Wrap a page body in the shell.
155pub fn document(page: &Page) -> String {
156 let mut out = String::with_capacity(page.body.len() + 2048);
157 out.push_str("<!doctype html>\n<html lang=\"en\">\n<head>\n");
158 out.push_str("<meta charset=\"utf-8\">\n");
159 out.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
160 out.push_str(&format!("<title>{}</title>\n", escape(&page.title)));
161 // Dark and light are both first-class; the browser picks. Declaring this stops
162 // form controls and scrollbars rendering light on a dark page.
163 out.push_str("<meta name=\"color-scheme\" content=\"light dark\">\n");
164 out.push_str(&format!(
165 "<link rel=\"stylesheet\" href=\"{}\">\n",
166 style_url()
167 ));
168 out.push_str("</head>\n<body>\n");
169
170 out.push_str("<header class=\"bar\">\n<div class=\"wrap bar-inner\">\n");
171 out.push_str(&format!(
172 "<a class=\"brand\" href=\"/\">{}</a>\n",
173 escape(brand::NAME)
174 ));
175 out.push_str(&format!("<div class=\"crumbs\">{}</div>\n", page.heading));
176 out.push_str("</div>\n</header>\n");
177
178 out.push_str("<main class=\"wrap\">\n");
179 out.push_str(&page.body);
180 out.push_str("\n</main>\n");
181
182 out.push_str("<footer class=\"wrap foot\">\n");
183 out.push_str(&format!(
184 "<span>served by {} {}</span>\n",
185 escape(brand::NAME),
186 escape(brand::VERSION)
187 ));
188 out.push_str("</footer>\n");
189
190 out.push_str("</body>\n</html>\n");
191 out
192}
193
194pub const STYLE: &str = r#"
195/* Repository viewer.
196
197 No framework and no build step: the service ships as one binary, and a
198 stylesheet that needed compiling would mean a toolchain in the release path. */
199
200*, *::before, *::after { box-sizing: border-box; }
201
202:root {
203 --bg: #ffffff;
204 --raised: #f7f8fa;
205 --line: #d8dce3;
206 --ink: #14171c;
207 --muted: #5b6472;
208 --link: #1f5fd0;
209 --accent: #7a3ddb;
210 --ok-bg: #e6f6ec;
211 --ok-ink: #1a7f40;
212 --warn-bg: #fdf1dd;
213 --warn-ink: #8a5a08;
214 --radius: 10px;
215 --mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", Menlo,
216 Consolas, "Liberation Mono", monospace;
217 --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
218 Arial, sans-serif;
219}
220
221@media (prefers-color-scheme: dark) {
222 :root {
223 --bg: #0f1216;
224 --raised: #161b22;
225 --line: #2a313b;
226 --ink: #e6e9ee;
227 --muted: #929cad;
228 --link: #74a8ff;
229 --accent: #b28bff;
230 --ok-bg: #12301f;
231 --ok-ink: #5fd08a;
232 --warn-bg: #33270f;
233 --warn-ink: #e0b055;
234 }
235}
236
237html { -webkit-text-size-adjust: 100%; }
238
239body {
240 margin: 0;
241 background: var(--bg);
242 color: var(--ink);
243 font-family: var(--sans);
244 font-size: 15px;
245 line-height: 1.6;
246 /* Kerning and ligatures off in tables of filenames would be better, but this is
247 body copy; the code views set their own. */
248 font-variant-ligatures: none;
249}
250
251.wrap { max-width: 1080px; margin: 0 auto; padding: 0 20px; }
252
253a { color: var(--link); text-decoration: none; }
254a:hover { text-decoration: underline; }
255
256/* ── header ─────────────────────────────────────────────────────────────── */
257
258.bar {
259 border-bottom: 1px solid var(--line);
260 background: var(--raised);
261 position: sticky;
262 top: 0;
263 z-index: 10;
264}
265
266.bar-inner {
267 display: flex;
268 align-items: center;
269 gap: 14px;
270 height: 52px;
271}
272
273.brand {
274 font-weight: 650;
275 letter-spacing: -0.01em;
276 color: var(--ink);
277 font-size: 16px;
278}
279.brand:hover { text-decoration: none; color: var(--accent); }
280
281.crumbs {
282 font-size: 14px;
283 color: var(--muted);
284 overflow: hidden;
285 text-overflow: ellipsis;
286 white-space: nowrap;
287}
288.crumbs a { font-weight: 550; }
289.crumbs .sep { color: var(--line); margin: 0 2px; }
290
291/* ── repository head ────────────────────────────────────────────────────── */
292
293.repo-head { padding: 28px 0 18px; border-bottom: 1px solid var(--line); }
294
295.repo-title {
296 margin: 0;
297 font-size: 26px;
298 font-weight: 660;
299 letter-spacing: -0.02em;
300 display: flex;
301 align-items: center;
302 gap: 10px;
303 flex-wrap: wrap;
304}
305
306.repo-desc { margin: 8px 0 0; color: var(--muted); max-width: 70ch; }
307
308.badge {
309 font-size: 11px;
310 font-weight: 600;
311 text-transform: uppercase;
312 letter-spacing: 0.06em;
313 padding: 3px 8px;
314 border-radius: 999px;
315 border: 1px solid var(--line);
316 color: var(--muted);
317 background: var(--bg);
318}
319.badge.public { background: var(--ok-bg); color: var(--ok-ink); border-color: transparent; }
320
321/* ── clone ──────────────────────────────────────────────────────────────── */
322
323.clone { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 16px; }
324
325.clone code {
326 font-family: var(--mono);
327 font-size: 12.5px;
328 background: var(--raised);
329 border: 1px solid var(--line);
330 border-radius: var(--radius);
331 padding: 7px 11px;
332 color: var(--ink);
333 user-select: all;
334}
335
336/* ── toolbar ────────────────────────────────────────────────────────────── */
337
338.toolbar {
339 display: flex;
340 align-items: center;
341 gap: 12px;
342 margin: 22px 0 12px;
343 flex-wrap: wrap;
344}
345
346.picker {
347 font-family: inherit;
348 font-size: 13px;
349 color: var(--ink);
350 background: var(--raised);
351 border: 1px solid var(--line);
352 border-radius: var(--radius);
353 padding: 6px 10px;
354}
355
356.spacer { flex: 1; }
357
358/* ── listing ────────────────────────────────────────────────────────────── */
359
360.panel {
361 border: 1px solid var(--line);
362 border-radius: var(--radius);
363 overflow: hidden;
364 background: var(--bg);
365}
366
367.panel + .panel { margin-top: 22px; }
368
369.panel-head {
370 padding: 10px 14px;
371 background: var(--raised);
372 border-bottom: 1px solid var(--line);
373 font-size: 13px;
374 color: var(--muted);
375 display: flex;
376 align-items: center;
377 gap: 8px;
378}
379
380table.listing { width: 100%; border-collapse: collapse; }
381
382table.listing td {
383 padding: 8px 14px;
384 border-top: 1px solid var(--line);
385 vertical-align: middle;
386}
387table.listing tr:first-child td { border-top: none; }
388table.listing tr:hover td { background: var(--raised); }
389
390td.name { width: 100%; }
391td.name a { color: var(--ink); }
392td.name a:hover { color: var(--link); }
393
394td.size, td.when {
395 color: var(--muted);
396 font-size: 12.5px;
397 text-align: right;
398 white-space: nowrap;
399 font-variant-numeric: tabular-nums;
400}
401
402.icon { color: var(--muted); margin-right: 8px; display: inline-block; width: 1em; }
403.icon.dir { color: var(--accent); }
404
405/* ── readme and prose ───────────────────────────────────────────────────── */
406
407.prose { padding: 26px 30px; }
408.prose > :first-child { margin-top: 0; }
409.prose > :last-child { margin-bottom: 0; }
410.prose h1, .prose h2, .prose h3 { letter-spacing: -0.02em; line-height: 1.3; margin: 1.6em 0 0.6em; }
411.prose h1 { font-size: 25px; }
412.prose h2 { font-size: 20px; border-bottom: 1px solid var(--line); padding-bottom: 6px; }
413.prose h3 { font-size: 17px; }
414.prose p, .prose ul, .prose ol { margin: 0 0 1em; }
415.prose img { max-width: 100%; }
416.prose blockquote {
417 margin: 0 0 1em;
418 padding: 2px 16px;
419 border-left: 3px solid var(--line);
420 color: var(--muted);
421}
422.prose code {
423 font-family: var(--mono);
424 font-size: 0.88em;
425 background: var(--raised);
426 padding: 2px 5px;
427 border-radius: 5px;
428}
429.prose pre {
430 background: var(--raised);
431 border: 1px solid var(--line);
432 border-radius: var(--radius);
433 padding: 14px 16px;
434 overflow-x: auto;
435}
436.prose pre code { background: none; padding: 0; font-size: 12.5px; }
437.prose table { border-collapse: collapse; margin-bottom: 1em; }
438.prose th, .prose td { border: 1px solid var(--line); padding: 6px 12px; }
439.prose th { background: var(--raised); }
440
441/* ── code ───────────────────────────────────────────────────────────────── */
442
443.code { overflow-x: auto; }
444
445table.code-table {
446 border-collapse: collapse;
447 font-family: var(--mono);
448 font-size: 12.5px;
449 line-height: 1.55;
450 width: 100%;
451}
452
453table.code-table td.ln {
454 width: 1%;
455 min-width: 46px;
456 padding: 0 12px 0 14px;
457 text-align: right;
458 color: var(--muted);
459 user-select: none;
460 vertical-align: top;
461 border-right: 1px solid var(--line);
462 font-variant-numeric: tabular-nums;
463}
464
465table.code-table td.ln a { color: inherit; }
466table.code-table td.src { padding: 0 14px; white-space: pre; vertical-align: top; }
467table.code-table tr:target td { background: var(--warn-bg); }
468table.code-table tr:target td.ln { color: var(--warn-ink); }
469
470/* ── commits ────────────────────────────────────────────────────────────── */
471
472.commit-row { display: flex; gap: 14px; align-items: baseline; }
473.commit-subject { font-weight: 550; color: var(--ink); }
474.commit-meta { color: var(--muted); font-size: 12.5px; }
475
476code.sha {
477 font-family: var(--mono);
478 font-size: 12px;
479 color: var(--muted);
480 background: var(--raised);
481 border: 1px solid var(--line);
482 border-radius: 6px;
483 padding: 1px 6px;
484}
485
486pre.message {
487 font-family: var(--mono);
488 font-size: 13px;
489 white-space: pre-wrap;
490 word-break: break-word;
491 margin: 0;
492 padding: 16px 18px;
493}
494
495.status { display: inline-block; width: 1.4em; font-family: var(--mono); color: var(--muted); }
496.status.A { color: var(--ok-ink); }
497.status.D { color: #d1424f; }
498
499/* ── notices ────────────────────────────────────────────────────────────── */
500
501.empty, .notice {
502 padding: 34px 20px;
503 text-align: center;
504 color: var(--muted);
505}
506
507.notice strong { color: var(--ink); display: block; font-size: 17px; margin-bottom: 6px; }
508
509.foot {
510 margin: 48px 0 30px;
511 padding-top: 18px;
512 border-top: 1px solid var(--line);
513 color: var(--muted);
514 font-size: 12.5px;
515}
516
517@media (max-width: 640px) {
518 .wrap { padding: 0 14px; }
519 td.when { display: none; }
520 .repo-title { font-size: 21px; }
521}
522"#;
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527
528 #[test]
529 fn escaping_covers_both_body_and_attribute_context() {
530 assert_eq!(escape("<script>"), "&lt;script&gt;");
531 assert_eq!(escape("a&b"), "a&amp;b");
532 // Quotes matter: a repository description lands inside an attribute, and a
533 // bare quote there ends the attribute and starts writing markup.
534 assert_eq!(escape("\"onload=x"), "&quot;onload=x");
535 assert_eq!(escape("'onload=x"), "&#39;onload=x");
536 }
537
538 #[test]
539 fn markdown_drops_embedded_html_rather_than_rendering_it() {
540 // Anyone who can push can write a README. On a public repository that is a
541 // stranger, so raw HTML in markdown is an attacker-supplied script tag.
542 let out = markdown("# hi\n\n<script>alert(1)</script>\n\n<img src=x onerror=y>");
543 assert!(!out.contains("<script"), "{out}");
544 assert!(!out.contains("onerror"), "{out}");
545 assert!(out.contains("<h1>hi</h1>"), "{out}");
546 }
547
548 #[test]
549 fn a_markdown_link_cannot_carry_an_executable_scheme() {
550 // A README is attacker-controlled on a public repository, and pulldown-cmark
551 // emits destinations verbatim, so this is the one place markdown can still
552 // execute after raw HTML has been dropped.
553 for hostile in [
554 "[x](javascript:alert(1))",
555 "[x](JaVaScRiPt:alert(1))",
556 "[x](java\tscript:alert(1))",
557 "[x](data:text/html;base64,PHNjcmlwdD4=)",
558 "[x](vbscript:msgbox)",
559 "![x](javascript:alert(1))",
560 ] {
561 let out = markdown(hostile);
562 assert!(
563 !out.to_lowercase().contains("javascript:")
564 && !out.to_lowercase().contains("vbscript:")
565 && !out.to_lowercase().contains("data:text/html"),
566 "{hostile} rendered as {out}"
567 );
568 }
569 }
570
571 #[test]
572 fn ordinary_links_are_left_alone() {
573 // Over-blocking would quietly break every README on the host.
574 for fine in [
575 "[x](https://example.com/a?b=c#d)",
576 "[x](http://example.com)",
577 "[x](docs/guide.md)",
578 "[x](./relative)",
579 "[x](#anchor)",
580 "[x](mailto:a@b.c)",
581 ] {
582 let out = markdown(fine);
583 assert!(!out.contains("#blocked"), "{fine} was blocked: {out}");
584 }
585 }
586
587 #[test]
588 fn markdown_still_escapes_text_that_looks_like_markup() {
589 let out = markdown("plain `<b>` and <not-a-tag>");
590 assert!(!out.contains("<b>"), "{out}");
591 }
592
593 #[test]
594 fn a_url_segment_cannot_carry_a_query_or_fragment() {
595 // A file legitimately named `a?b#c` would otherwise produce a link that
596 // silently addresses a different path.
597 assert_eq!(url_escape("a?b#c"), "a%3Fb%23c");
598 assert_eq!(url_escape("dir/name.txt"), "dir/name.txt");
599 assert_eq!(url_escape("a b"), "a%20b");
600 }
601
602 #[test]
603 fn the_stylesheet_url_changes_when_the_build_does() {
604 // It is served immutable for a year, so a deploy that reused the URL would
605 // leave every returning visitor on the previous stylesheet.
606 assert!(style_url().contains(brand::COMMIT));
607 }
608}