Skip to main content

feather_reader/
safe_link.rs

1//! The `href` type.
2//!
3//! **This is its own module because of the tuple field.** A private field is
4//! private to the MODULE, and `web.rs` is a single ~13,600-line file holding every
5//! `EntryRow` construction — so while the type lived there,
6//! `SafeLink("javascript:alert(1)")` compiled and rendered verbatim into an
7//! `href`. An adversarial review demonstrated exactly that, five different ways.
8//! Here the field is unreachable from `web.rs`, which is what makes "no bypass"
9//! structural rather than aspirational.
10
11/// A string that is safe to place in an `href`.
12///
13/// **Structural, not procedural — and that distinction is the whole point.** The
14/// saved-record path takes an attacker-controlled URL (any atproto client can
15/// write the record), and Askama escapes HTML metacharacters but NOT schemes, so
16/// `javascript:` survives escaping intact.
17///
18/// The defence used to be "remember to call `net::safe_link` before assigning
19/// this field". A cold review measured what that was worth: deleting the call
20/// left **all 679 tests passing**, because every test either exercised the helper
21/// directly or never rendered this row. The control was real and completely
22/// unprotected.
23///
24/// So the field is no longer a `String`. There is no `From<String>`, no public
25/// member, and neither constructor can carry a foreign URL into an `href`:
26/// [`SafeLink::external`] performs the scheme check itself, and
27/// [`SafeLink::entry`] takes an `i64` and a scope query rather than a string, so
28/// it cannot be handed one.
29pub struct SafeLink(String);
30
31impl SafeLink {
32    /// The reader's own link to a cached entry: `/entries/{id}`, plus the
33    /// list's scope query so paging back stays in the list it came from.
34    ///
35    /// **Takes the id and query separately and builds the path itself**, rather
36    /// than accepting a ready-made `String`. A `fn internal(String)` constructor
37    /// is an unrestricted `String` → `href` conduit sitting one identifier away
38    /// from the single site where attacker-controlled data enters — and a review
39    /// proved it, by typing `internal` where `external` was meant: the whole
40    /// `javascript:` hole came back, compiled clean, and passed every test.
41    ///
42    /// An `i64` and a scope query cannot spell a scheme. The result always
43    /// begins `/entries/`, so it is a path by construction, never a URL.
44    /// `scope_qs` is percent-encoded upstream by `qenc`.
45    pub fn entry(id: i64, scope_qs: &str) -> Self {
46        Self(if scope_qs.is_empty() {
47            format!("/entries/{id}")
48        } else {
49            format!("/entries/{id}?{scope_qs}")
50        })
51    }
52
53    /// Attacker-controlled input. Scheme-checked; an unusable URL yields an
54    /// EMPTY link, which the template renders as a row WITHOUT an anchor rather
55    /// than dropping the row — a dropped row is unremovable, because the un-save
56    /// button lives on it.
57    pub fn external(raw: &str) -> Self {
58        Self(crate::net::safe_link(raw).unwrap_or_default())
59    }
60
61    /// [`SafeLink::external`], for a template that omits the link rather than
62    /// rendering it empty.
63    ///
64    /// The two shapes are not interchangeable, and which one a call site wants
65    /// is decided by whether anything else lives on the link. The saved-record
66    /// row keeps the EMPTY link because dropping the row would take the un-save
67    /// button with it — the record would become unremovable from here. The
68    /// reader view has no such passenger: `entry.html` already renders a
69    /// disabled open-original button for an entry with no URL, so `None`
70    /// selects a path that exists and is styled, and an empty `href` would only
71    /// invent a third state meaning the same thing.
72    ///
73    /// It lives here rather than as `.filter(|l| !l.is_empty())` at the call
74    /// site so that "empty means refused" stays a fact of this module. That is
75    /// the same reason the field is in this file at all.
76    pub fn external_opt(raw: &str) -> Option<Self> {
77        let link = Self::external(raw);
78        (!link.is_empty()).then_some(link)
79    }
80
81    pub fn is_empty(&self) -> bool {
82        self.0.is_empty()
83    }
84}
85
86impl std::fmt::Display for SafeLink {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        f.write_str(&self.0)
89    }
90}