Skip to main content

isb_daemon/daemon/templates/
logo.rs

1//! Template logos, served from isb's own origin:
2//! `GET /api/v1/templates/<catalog>/<id>/logo`.
3//!
4//! A template's logo is a third-party https URL (a trademark, so isb links
5//! to it and never ships a copy). The page's CSP keeps `img-src` to isb
6//! itself, so browsers never reach the logo's host: the daemon fetches it
7//! once, keeps it under `<state>/templates/logos/` for a week, and serves
8//! it from there.
9//!
10//! What comes back is third-party data. Only https is fetched, every
11//! address is held to the notification SSRF policy (no loopback, private,
12//! link-local or other non-public destination, connected to the address
13//! that was checked), redirects stay on https and are capped, the body is
14//! capped at [`MAX_BYTES`], and only bytes that sniff as PNG, JPEG, GIF,
15//! WebP, ICO or SVG are kept and served, with the type isb decided and
16//! `nosniff`. An SVG is served under a sandboxing CSP too: through `<img>`
17//! it cannot run script anyway, but the URL may be opened directly.
18
19use std::collections::HashMap;
20use std::io::{Read, Write};
21use std::path::{Path, PathBuf};
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant, SystemTime};
24
25use crate::notify::net;
26use crate::server::access::ASSERTION_HEADER;
27use crate::server::http::{Peer, Request, Response};
28use crate::server::mcp::Authn;
29use crate::server::{AccessValidator, Authenticated, Caller};
30use crate::template::catalog::Catalogs;
31
32/// The largest logo kept.
33pub const MAX_BYTES: usize = 512 * 1024;
34/// How long a fetched logo is served before it is fetched again.
35const FRESH_FOR: Duration = Duration::from_secs(7 * 86400);
36/// How long a failed fetch is remembered (and not retried).
37const FAILED_FOR: Duration = Duration::from_secs(600);
38/// The longest one fetch may take, redirects included.
39const FETCH_TIMEOUT: Duration = Duration::from_secs(15);
40const MAX_REDIRECTS: usize = 3;
41/// How long the template-to-logo index is reused, and how soon a ref it
42/// lacks (a catalog just added) may rebuild it.
43const INDEX_FOR: Duration = Duration::from_secs(60);
44const INDEX_MISS_AFTER: Duration = Duration::from_secs(5);
45
46/// What a logo is, decided from its bytes.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum Kind {
49    Png,
50    Jpeg,
51    Gif,
52    Webp,
53    Ico,
54    Svg,
55}
56
57impl Kind {
58    pub fn mime(self) -> &'static str {
59        match self {
60            Kind::Png => "image/png",
61            Kind::Jpeg => "image/jpeg",
62            Kind::Gif => "image/gif",
63            Kind::Webp => "image/webp",
64            Kind::Ico => "image/x-icon",
65            Kind::Svg => "image/svg+xml",
66        }
67    }
68}
69
70/// An image's type from its first bytes, or `None` for anything else.
71pub fn sniff(b: &[u8]) -> Option<Kind> {
72    if b.starts_with(b"\x89PNG\r\n\x1a\n") {
73        return Some(Kind::Png);
74    }
75    if b.starts_with(&[0xff, 0xd8, 0xff]) {
76        return Some(Kind::Jpeg);
77    }
78    if b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a") {
79        return Some(Kind::Gif);
80    }
81    if b.len() >= 12 && &b[..4] == b"RIFF" && &b[8..12] == b"WEBP" {
82        return Some(Kind::Webp);
83    }
84    // ICONDIR: reserved 0, type 1, then at least one image.
85    if b.len() >= 22 && b[..4] == [0, 0, 1, 0] && (b[4] != 0 || b[5] != 0) {
86        return Some(Kind::Ico);
87    }
88    is_svg(b).then_some(Kind::Svg)
89}
90
91/// UTF-8 whose first element is `<svg`, after an optional BOM, XML
92/// declaration, comments and an svg doctype without an internal subset.
93fn is_svg(b: &[u8]) -> bool {
94    let Ok(s) = std::str::from_utf8(b) else {
95        return false;
96    };
97    let mut s = s.strip_prefix('\u{feff}').unwrap_or(s);
98    loop {
99        s = s.trim_start();
100        let skip = |s: &str, open: &str, close: &str| -> Option<usize> {
101            s.starts_with(open)
102                .then(|| s.find(close).map(|i| i + close.len()))
103                .flatten()
104        };
105        if s.starts_with("<?") {
106            match skip(s, "<?", "?>") {
107                Some(n) => s = &s[n..],
108                None => return false,
109            }
110        } else if s.starts_with("<!--") {
111            match skip(s, "<!--", "-->") {
112                Some(n) => s = &s[n..],
113                None => return false,
114            }
115        } else if s.len() >= 9 && s[..9].eq_ignore_ascii_case("<!doctype") {
116            let name = s[9..].trim_start();
117            let svg = name.len() > 3
118                && name[..3].eq_ignore_ascii_case("svg")
119                && name[3..].starts_with(|c: char| c.is_ascii_whitespace() || c == '>');
120            match s.find('>') {
121                Some(n) if svg && !s[..n].contains('[') => s = &s[n + 1..],
122                _ => return false,
123            }
124        } else {
125            break;
126        }
127    }
128    s.strip_prefix("<svg")
129        .and_then(|r| r.chars().next())
130        .is_some_and(|c| c.is_ascii_whitespace() || c == '>' || c == '/')
131}
132
133/// A logo as served.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct Image {
136    pub kind: Kind,
137    pub bytes: Vec<u8>,
138}
139
140impl Image {
141    fn of(bytes: Vec<u8>) -> Result<Image, String> {
142        if bytes.len() > MAX_BYTES {
143            return Err(format!("over {} KiB", MAX_BYTES / 1024));
144        }
145        let kind = sniff(&bytes).ok_or("not a PNG, JPEG, GIF, WebP, ICO or SVG image")?;
146        Ok(Image { kind, bytes })
147    }
148
149    pub fn response(&self) -> Response {
150        Response::new(200)
151            .header("Content-Type", self.kind.mime())
152            .header("X-Content-Type-Options", "nosniff")
153            .header("Cache-Control", "private, max-age=86400")
154            .header("Cross-Origin-Resource-Policy", "same-origin")
155            .header(
156                "Content-Security-Policy",
157                "default-src 'none'; style-src 'unsafe-inline'; sandbox",
158            )
159            .body(self.bytes.clone())
160    }
161}
162
163/// Reads a logo URL; a test can replace it.
164pub type FetchFn = Arc<dyn Fn(&str) -> Result<Vec<u8>, String> + Send + Sync>;
165
166/// The logo cache of one daemon.
167pub struct Logos {
168    dir: PathBuf,
169    fetch: FetchFn,
170    /// One fetch per URL at a time.
171    inflight: Mutex<HashMap<String, Arc<Mutex<()>>>>,
172}
173
174fn key(url: &str) -> String {
175    ring::digest::digest(&ring::digest::SHA256, url.as_bytes())
176        .as_ref()
177        .iter()
178        .map(|b| format!("{b:02x}"))
179        .collect()
180}
181
182fn age(p: &Path) -> Option<Duration> {
183    let m = std::fs::metadata(p).ok()?.modified().ok()?;
184    Some(SystemTime::now().duration_since(m).unwrap_or_default())
185}
186
187impl Logos {
188    pub fn new(state: &Path) -> Logos {
189        Logos::with_fetch(state, Arc::new(https_get))
190    }
191
192    pub fn with_fetch(state: &Path, fetch: FetchFn) -> Logos {
193        Logos {
194            dir: state.join("templates").join("logos"),
195            fetch,
196            inflight: Mutex::new(HashMap::new()),
197        }
198    }
199
200    /// The cached copy and its age, if it is still an image.
201    fn cached(&self, k: &str) -> Option<(Duration, Image)> {
202        let p = self.dir.join(k);
203        let a = age(&p)?;
204        let img = Image::of(std::fs::read(&p).ok()?).ok()?;
205        Some((a, img))
206    }
207
208    /// The logo at `url`: the cached copy while it is fresh, else fetched
209    /// again; a stale copy when that fails. `None` when there is nothing to
210    /// serve (a failure is remembered for a few minutes).
211    pub fn get(&self, url: &str) -> Option<Image> {
212        let k = key(url);
213        let failed = self.dir.join(format!("{k}.failed"));
214        let lookup = || -> (Option<Image>, bool) {
215            let cached = self.cached(&k);
216            let fresh = cached.as_ref().is_some_and(|(a, _)| *a < FRESH_FOR)
217                || age(&failed).is_some_and(|a| a < FAILED_FOR);
218            (cached.map(|(_, i)| i), fresh)
219        };
220        if let (img, true) = lookup() {
221            return img;
222        }
223        let lock = self
224            .inflight
225            .lock()
226            .unwrap()
227            .entry(k.clone())
228            .or_default()
229            .clone();
230        let held = lock.lock().unwrap();
231        // Another request may have fetched it while this one waited.
232        let (stale, fresh) = lookup();
233        let out = if fresh {
234            stale
235        } else {
236            match (self.fetch)(url).and_then(Image::of) {
237                Ok(img) => {
238                    if let Err(e) = crate::app::write_atomic(&self.dir.join(&k), &img.bytes) {
239                        eprintln!("isb serve: template logo cache: {e}");
240                    }
241                    let _ = std::fs::remove_file(&failed);
242                    Some(img)
243                }
244                Err(e) => {
245                    let host = net::parse_url(url).map(|t| t.host).unwrap_or_default();
246                    eprintln!("isb serve: template logo from {host}: {e}");
247                    if let Err(e) = crate::app::write_atomic(&failed, b"") {
248                        eprintln!("isb serve: template logo cache: {e}");
249                    }
250                    stale
251                }
252            }
253        };
254        drop(held);
255        self.inflight.lock().unwrap().remove(&k);
256        out
257    }
258}
259
260/// What one HTTP exchange came to.
261#[derive(Debug, PartialEq, Eq)]
262enum Answer {
263    Body(Vec<u8>),
264    Redirect(String),
265}
266
267/// GET an https URL under the SSRF policy: at most [`MAX_BYTES`], within
268/// `FETCH_TIMEOUT`, following up to `MAX_REDIRECTS` redirects that stay
269/// on https.
270pub fn https_get(url: &str) -> Result<Vec<u8>, String> {
271    let started = Instant::now();
272    let mut url = url.to_string();
273    for _ in 0..=MAX_REDIRECTS {
274        let t = net::parse_url(&url)?;
275        if !t.https {
276            return Err("logos are fetched over https only".into());
277        }
278        // The notification policy, never relaxed for logos.
279        let policy = |e: net::SendError| {
280            let m = e.message;
281            m.split("; private targets are off")
282                .next()
283                .unwrap_or(&m)
284                .to_string()
285        };
286        let tcp = net::connect(&t.host, t.port, false).map_err(policy)?;
287        let mut s = net::tls(&net::Net::new(false), &t.host, tcp).map_err(|e| e.message)?;
288        let host = if t.host.contains(':') {
289            format!("[{}]", t.host)
290        } else {
291            t.host.clone()
292        };
293        let host = if t.port == 443 {
294            host
295        } else {
296            format!("{host}:{}", t.port)
297        };
298        let head = format!(
299            "GET {} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: isb/{}\r\nAccept: image/*\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n",
300            t.path,
301            env!("CARGO_PKG_VERSION")
302        );
303        let io = |e: std::io::Error| format!("{}: {e}", t.host);
304        s.write_all(head.as_bytes()).map_err(io)?;
305        s.flush().map_err(io)?;
306        let raw = read_capped(&mut s, started)?;
307        match parse(&raw)? {
308            Answer::Body(b) => return Ok(b),
309            Answer::Redirect(loc) => url = redirect(&t, &loc)?,
310        }
311    }
312    Err(format!("more than {MAX_REDIRECTS} redirects"))
313}
314
315/// Where a redirect from `from` to `location` goes: https only.
316fn redirect(from: &net::Target, location: &str) -> Result<String, String> {
317    let l = location.trim();
318    let next = if l.starts_with("//") {
319        format!("https:{l}")
320    } else if l.starts_with('/') {
321        let host = if from.host.contains(':') {
322            format!("[{}]", from.host)
323        } else {
324            from.host.clone()
325        };
326        format!("https://{host}:{}{l}", from.port)
327    } else {
328        l.to_string()
329    };
330    if !next
331        .get(..8)
332        .is_some_and(|p| p.eq_ignore_ascii_case("https://"))
333    {
334        return Err("a redirect off https".into());
335    }
336    Ok(next)
337}
338
339/// The whole answer, refusing one past the cap.
340fn read_capped<S: Read>(s: &mut S, started: Instant) -> Result<Vec<u8>, String> {
341    // Room for the headers and chunk framing on top of the body.
342    let cap = MAX_BYTES + 64 * 1024;
343    let mut out = Vec::new();
344    let mut buf = [0u8; 16 * 1024];
345    loop {
346        if started.elapsed() > FETCH_TIMEOUT {
347            return Err(format!("no answer within {FETCH_TIMEOUT:?}"));
348        }
349        match s.read(&mut buf) {
350            Ok(0) => break,
351            Ok(n) => {
352                out.extend_from_slice(&buf[..n]);
353                if out.len() > cap {
354                    return Err(format!("over {} KiB", MAX_BYTES / 1024));
355                }
356                if complete(&out) {
357                    break;
358                }
359            }
360            // A peer that closes without TLS close_notify, after answering.
361            Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof && !out.is_empty() => break,
362            Err(e) => return Err(e.to_string()),
363        }
364    }
365    Ok(out)
366}
367
368fn header_end(b: &[u8]) -> Option<usize> {
369    b.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
370}
371
372/// Headers and a Content-Length body are in (a server that holds the
373/// connection open despite `Connection: close` does not hold us up).
374fn complete(b: &[u8]) -> bool {
375    let Some(end) = header_end(b) else {
376        return false;
377    };
378    let mut headers = [httparse::EMPTY_HEADER; 64];
379    let mut r = httparse::Response::new(&mut headers);
380    if r.parse(b).is_err() {
381        return false;
382    }
383    let len = r
384        .headers
385        .iter()
386        .find(|h| h.name.eq_ignore_ascii_case("content-length"))
387        .and_then(|h| {
388            std::str::from_utf8(h.value)
389                .ok()?
390                .trim()
391                .parse::<usize>()
392                .ok()
393        });
394    matches!(len, Some(n) if b.len() >= end + n)
395}
396
397fn parse(raw: &[u8]) -> Result<Answer, String> {
398    let mut headers = [httparse::EMPTY_HEADER; 64];
399    let mut r = httparse::Response::new(&mut headers);
400    let end = match r.parse(raw) {
401        Ok(httparse::Status::Complete(n)) => n,
402        Ok(httparse::Status::Partial) => return Err("an incomplete HTTP answer".into()),
403        Err(e) => return Err(format!("a bad HTTP answer: {e}")),
404    };
405    let status = r.code.unwrap_or(0);
406    let header = |name: &str| {
407        r.headers
408            .iter()
409            .find(|h| h.name.eq_ignore_ascii_case(name))
410            .and_then(|h| std::str::from_utf8(h.value).ok())
411            .map(str::trim)
412    };
413    if matches!(status, 301 | 302 | 303 | 307 | 308) {
414        return header("location")
415            .map(|l| Answer::Redirect(l.to_string()))
416            .ok_or_else(|| format!("HTTP {status} without a Location"));
417    }
418    if status != 200 {
419        return Err(format!("HTTP {status}"));
420    }
421    if header("content-encoding").is_some_and(|e| !e.eq_ignore_ascii_case("identity")) {
422        return Err("an encoded body".into());
423    }
424    let body = &raw[end..];
425    let body = if header("transfer-encoding").is_some_and(|t| t.eq_ignore_ascii_case("chunked")) {
426        dechunk(body)?
427    } else {
428        match header("content-length").and_then(|l| l.parse::<usize>().ok()) {
429            Some(n) if n > MAX_BYTES => return Err(format!("over {} KiB", MAX_BYTES / 1024)),
430            Some(n) if body.len() < n => return Err("a truncated body".into()),
431            Some(n) => body[..n].to_vec(),
432            None => body.to_vec(),
433        }
434    };
435    if body.len() > MAX_BYTES {
436        return Err(format!("over {} KiB", MAX_BYTES / 1024));
437    }
438    Ok(Answer::Body(body))
439}
440
441fn dechunk(mut b: &[u8]) -> Result<Vec<u8>, String> {
442    let bad = || "a bad chunked body".to_string();
443    let mut out = Vec::new();
444    loop {
445        let line = b.windows(2).position(|w| w == b"\r\n").ok_or_else(bad)?;
446        let size = std::str::from_utf8(&b[..line]).map_err(|_| bad())?;
447        let size = size.split(';').next().unwrap_or_default().trim();
448        let n = usize::from_str_radix(size, 16).map_err(|_| bad())?;
449        b = &b[line + 2..];
450        if n == 0 {
451            return Ok(out);
452        }
453        if n > MAX_BYTES || b.len() < n + 2 {
454            return Err(bad());
455        }
456        out.extend_from_slice(&b[..n]);
457        b = &b[n + 2..];
458    }
459}
460
461/// May this request see template logos? The same callers as
462/// `template_list`.
463pub type Admit = Arc<dyn Fn(&Request) -> Result<(), Response> + Send + Sync>;
464
465/// Admit who `template_list` admits: the caller as the tool endpoints
466/// resolve it, then the same authorizer.
467pub fn admit(authn: Authn, access: Option<Arc<AccessValidator>>, allow_anonymous: bool) -> Admit {
468    Arc::new(move |req: &Request| {
469        let deny = |status: u16, m: &str| {
470            Response::json(
471                status,
472                &serde_json::json!({"error": "forbidden", "message": m}),
473            )
474        };
475        // The listener already checked an assertion when Access guards it;
476        // one sent anyway (a tailnet listener) must still be valid.
477        let id = match (&access, req.header(ASSERTION_HEADER)) {
478            (Some(v), Some(t)) => Some(
479                v.validate(t.trim())
480                    .map_err(|_| deny(401, "invalid Cloudflare Access assertion"))?,
481            ),
482            _ => None,
483        };
484        let caller = match authn(req, id.as_ref()) {
485            Authenticated::User(p) => Caller::User { principal: p },
486            Authenticated::Superadmin(s) => {
487                if s.source.is_ambient() {
488                    crate::server::mcp::ambient_ok(req).map_err(|why| deny(403, why))?;
489                }
490                Caller::Superadmin(s)
491            }
492            Authenticated::Refused => return Err(deny(401, "invalid credentials")),
493            Authenticated::None => match (id, &req.peer) {
494                (Some(id), _) => Caller::Access(id),
495                (None, Peer::Unix { uid }) => Caller::Local { uid: *uid },
496                (None, Peer::Tcp(addr)) => Caller::Unauthenticated { addr: *addr },
497            },
498        };
499        let read = super::super::audit::Class {
500            read_only: true,
501            secret_read: false,
502        };
503        super::super::authorize_class(
504            &caller,
505            "template_list",
506            read,
507            serde_json::json!({}),
508            None,
509            allow_anonymous,
510        )
511        .map(|_| ())
512        .map_err(|e| {
513            let status = if matches!(caller, Caller::Unauthenticated { .. }) {
514                401
515            } else {
516                403
517            };
518            deny(status, &e.to_string())
519        })
520    })
521}
522
523/// Template refs to logo URLs, rebuilt now and then.
524type Index = Mutex<Option<(Instant, Arc<HashMap<String, String>>)>>;
525
526fn logo_of(catalogs: &Catalogs, index: &Index, reference: &str) -> Option<String> {
527    let mut g = index.lock().unwrap();
528    let stale = g.as_ref().is_none_or(|(at, m)| {
529        at.elapsed() >= INDEX_FOR
530            || (at.elapsed() >= INDEX_MISS_AFTER && !m.contains_key(reference))
531    });
532    if stale {
533        let (all, _) = catalogs.list();
534        let m = all
535            .into_iter()
536            .filter_map(|s| Some((s.reference, s.logo?)))
537            .collect();
538        *g = Some((Instant::now(), Arc::new(m)));
539    }
540    g.as_ref()?.1.get(reference).cloned()
541}
542
543/// `GET /api/v1/templates/<catalog>/<id>/logo`: the template's logo from
544/// isb's cache, or a 404 when it has none or it could not be fetched.
545pub fn route(catalogs: Arc<Catalogs>, logos: Arc<Logos>, admit: Admit) -> crate::server::Routes {
546    let index: Arc<Index> = Arc::new(Mutex::new(None));
547    Arc::new(move |req: &Request| {
548        let reference = req
549            .path
550            .strip_prefix("/api/v1/templates/")?
551            .strip_suffix("/logo")?;
552        let (catalog, id) = reference.split_once('/')?;
553        if catalog.is_empty() || id.is_empty() || id.contains('/') {
554            return None;
555        }
556        if !matches!(req.method.as_str(), "GET" | "HEAD") {
557            return Some(Response::text(405, "method not allowed").header("Allow", "GET, HEAD"));
558        }
559        if let Err(r) = admit(req) {
560            return Some(r);
561        }
562        let none = || Response::text(404, "no logo").header("Cache-Control", "no-store");
563        let Some(url) = logo_of(&catalogs, &index, reference) else {
564            return Some(none());
565        };
566        let mut r = match logos.get(&url) {
567            Some(img) => img.response(),
568            None => none(),
569        };
570        if req.method == "HEAD" {
571            r.body.clear();
572        }
573        Some(r)
574    })
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580    use std::sync::atomic::{AtomicUsize, Ordering};
581
582    const PNG: &[u8] = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR";
583    const SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"/>"#;
584
585    fn ico() -> Vec<u8> {
586        let mut b = vec![0, 0, 1, 0, 1, 0];
587        b.extend_from_slice(&[0; 16]);
588        b
589    }
590
591    #[test]
592    fn sniffs_image_types_only() {
593        assert_eq!(sniff(PNG), Some(Kind::Png));
594        assert_eq!(sniff(b"\xff\xd8\xff\xe0\0\x10JFIF"), Some(Kind::Jpeg));
595        assert_eq!(sniff(b"GIF89a\x01\0"), Some(Kind::Gif));
596        assert_eq!(sniff(b"RIFF\0\0\0\0WEBPVP8 "), Some(Kind::Webp));
597        assert_eq!(sniff(&ico()), Some(Kind::Ico));
598        assert_eq!(sniff(SVG), Some(Kind::Svg));
599        assert_eq!(
600            sniff(
601                b"\xef\xbb\xbf<?xml version=\"1.0\"?>\n<!-- hi -->\n<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"x.dtd\">\n<svg width=\"1\">"
602            ),
603            Some(Kind::Svg)
604        );
605        for not in [
606            &b"<html><svg></svg></html>"[..],
607            b"<!doctype html><svg>",
608            b"<svgx>",
609            b"<!DOCTYPE svg [<!ENTITY x \"y\">]><svg>",
610            b"<!-- unclosed <svg>",
611            b"{\"not\": \"an image\"}",
612            b"RIFF\0\0\0\0WAVE",
613            b"\0\0\x01\0\0\0",
614            b"",
615            b"\xff\xfe<\0s\0v\0g\0",
616        ] {
617            assert_eq!(sniff(not), None, "{:?}", String::from_utf8_lossy(not));
618        }
619    }
620
621    #[test]
622    fn caps_the_size() {
623        let mut big = PNG.to_vec();
624        big.resize(MAX_BYTES, 0);
625        assert!(Image::of(big.clone()).is_ok());
626        big.push(0);
627        assert!(Image::of(big).unwrap_err().contains("512 KiB"));
628        let head = format!(
629            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
630            MAX_BYTES + 1
631        );
632        assert!(parse(head.as_bytes()).unwrap_err().contains("512 KiB"));
633        let mut s = std::io::Cursor::new(vec![b'x'; MAX_BYTES + 128 * 1024]);
634        assert!(read_capped(&mut s, Instant::now()).is_err());
635    }
636
637    #[test]
638    fn https_and_public_addresses_only() {
639        for (url, why) in [
640            ("http://example.com/logo.png", "https only"),
641            ("ftp://example.com/logo.png", "http"),
642            ("https://127.0.0.1/logo.png", "loopback"),
643            ("https://localhost./logo.png", ""),
644            ("https://10.1.2.3/logo.png", "private"),
645            ("https://169.254.169.254/latest/meta-data", "link-local"),
646            ("https://[::1]/logo.png", "loopback"),
647            ("https://[fd00::1]/logo.png", "unique local"),
648            ("https://0x7f000001/logo.png", "loopback"),
649            ("https://user@example.com/logo.png", "credentials"),
650        ] {
651            let e = https_get(url).unwrap_err();
652            assert!(e.contains(why), "{url}: {e}");
653        }
654        let from = net::parse_url("https://cdn.example.com/a/b.png").unwrap();
655        assert_eq!(
656            redirect(&from, "/c.png").unwrap(),
657            "https://cdn.example.com:443/c.png"
658        );
659        assert_eq!(
660            redirect(&from, "//other.example/x.svg").unwrap(),
661            "https://other.example/x.svg"
662        );
663        assert!(redirect(&from, "http://cdn.example.com/c.png").is_err());
664        assert!(redirect(&from, "c.png").is_err());
665    }
666
667    #[test]
668    fn parses_answers() {
669        let ok = b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nabcdef";
670        assert_eq!(parse(ok).unwrap(), Answer::Body(b"abc".to_vec()));
671        let chunked =
672            b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n2;x=y\r\nde\r\n0\r\n\r\n";
673        assert_eq!(parse(chunked).unwrap(), Answer::Body(b"abcde".to_vec()));
674        let moved = b"HTTP/1.1 302 Found\r\nLocation: /x.png\r\n\r\n";
675        assert_eq!(parse(moved).unwrap(), Answer::Redirect("/x.png".into()));
676        assert!(parse(b"HTTP/1.1 404 Not Found\r\n\r\n").is_err());
677        assert!(parse(b"HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\n\r\nxx").is_err());
678        assert!(parse(b"HTTP/1.1 200 OK\r\nContent-Length: 9\r\n\r\nabc").is_err());
679        assert!(dechunk(b"zz\r\n").is_err());
680        assert!(complete(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nab"));
681        assert!(!complete(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\na"));
682    }
683
684    fn set_age(p: &Path, d: Duration) {
685        let f = std::fs::File::options().write(true).open(p).unwrap();
686        f.set_modified(SystemTime::now() - d).unwrap();
687    }
688
689    /// A cache whose fetches are counted and answered from `answer`.
690    fn logos(dir: &Path, answer: Arc<Mutex<Result<Vec<u8>, String>>>) -> (Logos, Arc<AtomicUsize>) {
691        let n = Arc::new(AtomicUsize::new(0));
692        let n2 = n.clone();
693        let l = Logos::with_fetch(
694            dir,
695            Arc::new(move |_u: &str| {
696                n2.fetch_add(1, Ordering::SeqCst);
697                answer.lock().unwrap().clone()
698            }),
699        );
700        (l, n)
701    }
702
703    #[test]
704    fn caches_and_expires() {
705        let dir = tempfile::tempdir().unwrap();
706        let answer = Arc::new(Mutex::new(Ok(PNG.to_vec())));
707        let (l, n) = logos(dir.path(), answer.clone());
708        let url = "https://cdn.example.com/logo.png";
709        assert_eq!(l.get(url).unwrap().kind, Kind::Png);
710        assert_eq!(l.get(url).unwrap().bytes, PNG);
711        assert_eq!(n.load(Ordering::SeqCst), 1, "the second read is a hit");
712        let file = dir.path().join("templates/logos").join(key(url));
713        assert_eq!(std::fs::read(&file).unwrap(), PNG);
714        // A week on, it is fetched again.
715        set_age(&file, FRESH_FOR + Duration::from_secs(1));
716        *answer.lock().unwrap() = Ok(SVG.to_vec());
717        assert_eq!(l.get(url).unwrap().kind, Kind::Svg);
718        assert_eq!(n.load(Ordering::SeqCst), 2);
719        // A failed refetch serves the stale copy, and is not retried at once.
720        set_age(&file, FRESH_FOR + Duration::from_secs(1));
721        *answer.lock().unwrap() = Err("HTTP 500".into());
722        assert_eq!(l.get(url).unwrap().kind, Kind::Svg);
723        assert_eq!(l.get(url).unwrap().kind, Kind::Svg);
724        assert_eq!(n.load(Ordering::SeqCst), 3);
725    }
726
727    #[test]
728    fn remembers_failures_briefly() {
729        let dir = tempfile::tempdir().unwrap();
730        let answer = Arc::new(Mutex::new(Ok(b"<html>not a logo</html>".to_vec())));
731        let (l, n) = logos(dir.path(), answer.clone());
732        let url = "https://cdn.example.com/x";
733        assert!(l.get(url).is_none());
734        assert!(l.get(url).is_none());
735        assert_eq!(n.load(Ordering::SeqCst), 1);
736        assert!(!dir.path().join("templates/logos").join(key(url)).exists());
737        let failed = dir
738            .path()
739            .join("templates/logos")
740            .join(format!("{}.failed", key(url)));
741        set_age(&failed, FAILED_FOR + Duration::from_secs(1));
742        *answer.lock().unwrap() = Ok(ico());
743        assert_eq!(l.get(url).unwrap().kind, Kind::Ico);
744        assert_eq!(n.load(Ordering::SeqCst), 2);
745        assert!(!failed.exists());
746    }
747
748    #[test]
749    fn serves_with_strict_headers() {
750        let r = Image::of(SVG.to_vec()).unwrap().response();
751        assert_eq!(r.status, 200);
752        assert_eq!(r.get_header("content-type"), Some("image/svg+xml"));
753        assert_eq!(r.get_header("x-content-type-options"), Some("nosniff"));
754        assert_eq!(
755            r.get_header("cache-control"),
756            Some("private, max-age=86400")
757        );
758        assert_eq!(
759            r.get_header("content-security-policy"),
760            Some("default-src 'none'; style-src 'unsafe-inline'; sandbox")
761        );
762        let r = Image::of(PNG.to_vec()).unwrap().response();
763        assert_eq!(r.get_header("content-type"), Some("image/png"));
764    }
765
766    fn req(method: &str, path: &str) -> Request {
767        Request {
768            method: method.into(),
769            path: path.into(),
770            query: None,
771            headers: vec![],
772            body: vec![],
773            peer: Peer::Tcp("127.0.0.1:5000".parse().unwrap()),
774        }
775    }
776
777    #[test]
778    fn route_serves_catalog_logos_to_admitted_callers() {
779        let dir = tempfile::tempdir().unwrap();
780        let catalogs = Arc::new(Catalogs::with_fetch(
781            dir.path(),
782            Arc::new(|_: &str| Err(crate::error::Error::invalid("offline"))),
783        ));
784        let fetched = Arc::new(Mutex::new(Vec::<String>::new()));
785        let f2 = fetched.clone();
786        let logos = Arc::new(Logos::with_fetch(
787            dir.path(),
788            Arc::new(move |u: &str| {
789                f2.lock().unwrap().push(u.to_string());
790                Ok(SVG.to_vec())
791            }),
792        ));
793        let open: Admit = Arc::new(|_| Ok(()));
794        let r = route(catalogs.clone(), logos.clone(), open);
795        let get = |m: &str, p: &str| r(&req(m, p));
796        let ok = get("GET", "/api/v1/templates/builtin/gitea/logo").unwrap();
797        assert_eq!(ok.status, 200);
798        assert_eq!(ok.get_header("content-type"), Some("image/svg+xml"));
799        assert_eq!(ok.body, SVG);
800        let gitea = catalogs.get("builtin/gitea").unwrap().summary.logo.unwrap();
801        assert_eq!(fetched.lock().unwrap().as_slice(), [gitea]);
802        let head = get("HEAD", "/api/v1/templates/builtin/gitea/logo").unwrap();
803        assert_eq!((head.status, head.body.len()), (200, 0));
804        // whoami has no logo; nothing is fetched for it or an unknown ref.
805        assert_eq!(
806            get("GET", "/api/v1/templates/builtin/whoami/logo")
807                .unwrap()
808                .status,
809            404
810        );
811        assert_eq!(
812            get("GET", "/api/v1/templates/builtin/nope/logo")
813                .unwrap()
814                .status,
815            404
816        );
817        assert_eq!(fetched.lock().unwrap().len(), 1);
818        assert_eq!(
819            get("POST", "/api/v1/templates/builtin/gitea/logo")
820                .unwrap()
821                .status,
822            405
823        );
824        for other in [
825            "/api/v1/templates/gitea/logo",
826            "/api/v1/templates/builtin/a/b/logo",
827            "/api/v1/templates/builtin/gitea",
828            "/api/v1/tools/template_list",
829        ] {
830            assert!(get("GET", other).is_none(), "{other}");
831        }
832        // Every built-in logo is an https URL.
833        for s in catalogs.list().0 {
834            if let Some(l) = s.logo {
835                assert!(l.starts_with("https://"), "{}: {l}", s.reference);
836            }
837        }
838        let shut: Admit = Arc::new(|_| Err(Response::text(401, "sign in")));
839        let r = route(catalogs, logos, shut);
840        assert_eq!(
841            r(&req("GET", "/api/v1/templates/builtin/gitea/logo"))
842                .unwrap()
843                .status,
844            401
845        );
846    }
847
848    #[test]
849    fn admits_whom_template_list_admits() {
850        let none: Authn = Arc::new(|_, _| Authenticated::None);
851        let refused: Authn = Arc::new(|_, _| Authenticated::Refused);
852        let r = req("GET", "/api/v1/templates/builtin/gitea/logo");
853        assert_eq!(
854            admit(none.clone(), None, false)(&r).unwrap_err().status,
855            401
856        );
857        assert!(admit(none, None, true)(&r).is_ok());
858        assert_eq!(admit(refused, None, true)(&r).unwrap_err().status, 401);
859    }
860}