Refuse cross-site preference changes and redirects off Twitter, YouTube, and Reddit.

Show an account's country as a flag, and escape raw tags in posts and bios.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
4cuckandCursor committed 2026-10-25 03:31:12 +00:00
1 parent e93d659d21
commit d00136558a
17 files changed
+564 -44

No files matched your search

+1 -1
View File
@@ -35,7 +35,7 @@ enableRSS = true
# static assets are never charged, since they cost no upstream call.
clientBudget = true
# Peers whose X-Forwarded-For and X-Real-IP name the real client. Anything not
# Peers whose X-Forwarded-For names the real client. Anything not
# listed is billed by its socket address, so behind a reverse proxy this MUST
# list that proxy or every visitor shares one budget. The header is read right
# to left past trusted hops, since proxies append rather than replace.
+3 -3
View File
@@ -211,9 +211,9 @@
default = [ ];
example = [ "::1" ];
description = ''
Peers whose X-Forwarded-For and X-Real-IP headers name the real
client. Any other peer is billed by its socket address, since
those headers are otherwise caller-controlled.
Peers whose X-Forwarded-For header names the real client. Any
other peer is billed by its socket address, since the header is
otherwise caller-controlled.
'';
};
clientBudgetMessage = lib.mkOption {
+19 -1
View File
@@ -266,10 +266,12 @@ main .container {
vertical-align: middle;
}
.verified-icon svg { display: block; width: 15px; height: 15px; fill: currentColor; }
.verified-icon .verified-badge-bg { fill: currentColor; }
.verified-icon .verified-badge-check { fill: #fff; }
.verified-icon.blue { color: var(--verified_blue); }
.verified-icon.business { color: var(--verified_business); }
.verified-icon.government { color: var(--verified_government); }
.tweet-content { color: var(--text); }
.tweet-content { color: var(--text); font-weight: 400; }
.tweet-stats { color: var(--muted); }
.retweet-header, .pinned { color: var(--muted); font-size: 13px; }
.tweet-link:hover { background: transparent; }
@@ -283,6 +285,10 @@ main .container {
pointer-events: all;
}
.tweet-account-trigger[open] { z-index: 90; }
.timeline:has(.tweet-account-trigger[open]),
.quote:has(.tweet-account-trigger[open]) {
overflow: visible;
}
.timeline-item:has(.tweet-account-trigger[open]) {
z-index: 95;
overflow: visible;
@@ -648,6 +654,18 @@ main .container {
background: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 13%, transparent);
}
.account-signal-flag {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
font-size: 13px;
line-height: 1;
}
.account-signal > .icon-location,
.tweet-account-location > .icon-location {
color: var(--accent);
}
.profile-card-extra { display: block; }
.profile-card-extra .profile-bio { margin: 16px 0 12px; font-size: 14px; }
.profile-card-extra-links { margin-top: 14px; padding-top: 12px; }
+2 -2
View File
@@ -1,4 +1,4 @@
@import "tweet/base.css?teapawt3";
@import "tweet/base.css?teapawt4";
@import "profile/base.css?teapawt1";
@import "general.css";
@import "navbar.css";
@@ -23,7 +23,7 @@ body {
outline: unset;
margin: 0;
text-decoration: none;
font-weight: 300;
font-weight: 400;
}
h1 {
+1
View File
@@ -16,6 +16,7 @@
.tweet-content {
line-height: 1.3em;
font-weight: 450;
pointer-events: all;
display: inline;
}
+13
View File
@@ -1,6 +1,19 @@
"use strict";
document.addEventListener("click", async (event) => {
const target = event.target;
const accountDetails = target instanceof Element
? target.closest(".tweet-account-trigger, .account-context-trigger")
: null;
if (!accountDetails) {
document
.querySelectorAll(".tweet-account-trigger[open], .account-context-trigger[open]")
.forEach((details) => {
details.removeAttribute("open");
});
}
const button = event.target.closest(".copy-btn");
if (!button) return;
+1 -6
View File
@@ -196,12 +196,7 @@ pub fn client_from(peer: IpAddr, headers: &HeaderMap, trusted: &[IpAddr]) -> Cli
.filter_map(|entry| entry.trim().parse::<IpAddr>().ok())
.find(|address| !trusted.contains(address));
let real_ip = || {
let value = headers.get("x-real-ip")?.to_str().ok()?;
value.trim().parse::<IpAddr>().ok()
};
ClientKey::from_ip(forwarded.or_else(real_ip).unwrap_or(peer))
ClientKey::from_ip(forwarded.unwrap_or(peer))
}
impl ClientBudget {
+25
View File
@@ -48,8 +48,33 @@ pub async fn client_middleware(
budget::CLIENT.scope(client, next.run(request)).await
}
/// Browsers without `Sec-Fetch-Site` predate 2023, and non-browser clients
/// cannot be lured into a request, so a missing header passes.
fn is_cross_site(request: &Request) -> bool {
request
.headers()
.get("sec-fetch-site")
.is_some_and(|value| !matches!(value.as_bytes(), b"same-origin" | b"none"))
}
/// Refuse preference writes submitted from another site's form.
pub async fn same_origin_guard(request: Request, next: Next) -> Response {
if is_cross_site(&request) {
return Error::InvalidUrl("Settings can only be changed from this site".into())
.into_response();
}
next.run(request).await
}
/// Middleware that applies `?prefs=` URL parameter overrides.
///
/// A link from another site could otherwise switch off the media proxy or
/// point redirects elsewhere, so cross-site navigations get no overrides.
pub async fn prefs_middleware(mut request: Request, next: Next) -> Response {
if is_cross_site(&request) {
return next.run(request).await;
}
let uri = request.uri().clone();
let query_string = uri.query().unwrap_or("");
+4 -1
View File
@@ -4,6 +4,7 @@ use axum::{
Form, Router,
extract::{Query, State},
http::{HeaderMap, header::REFERER},
middleware,
response::{Html, IntoResponse as _, Redirect, Response},
routing::{get, post},
};
@@ -16,6 +17,7 @@ use crate::{
AppState,
config::Config,
error::Result,
routes::middleware::same_origin_guard,
types::Prefs,
views::{layout, preferences as pref_view},
};
@@ -134,12 +136,13 @@ fn make_cookie(
pub fn router() -> Router<AppState> {
Router::new()
.route("/settings", get(settings))
.route("/saveprefs", post(save_prefs))
.route("/resetprefs", post(reset_prefs))
.route("/enablemp4", post(enable_mp4))
.route("/enablehls", post(enable_hls))
.route("/ignorefirefoxwarning", post(ignore_firefox_warning))
.route_layer(middleware::from_fn(same_origin_guard))
.route("/settings", get(settings))
}
#[derive(Debug, Deserialize)]
+54
View File
@@ -1,6 +1,7 @@
use axum::{
Router,
extract::{Path, Query, State},
http::Uri,
response::{IntoResponse as _, Redirect, Response},
routing::get,
};
@@ -40,6 +41,9 @@ async fn redirect_handler(
// Decode URL if needed
let decoded_url = percent_decode(url);
if !is_redirectable(&decoded_url) {
return Err(Error::InvalidUrl("Redirect target is not allowed".into()));
}
// Get user preferences for URL replacements
let prefs = Prefs::from_cookies(&jar, &state.config);
@@ -50,6 +54,33 @@ async fn redirect_handler(
Ok(Redirect::to(&transformed).into_response())
}
fn is_redirectable(url: &str) -> bool {
const HOSTS: [&str; 5] = [
"twitter.com",
"x.com",
"youtube.com",
"youtu.be",
"reddit.com",
];
let Ok(uri) = url.parse::<Uri>() else {
return false;
};
if !matches!(uri.scheme_str(), Some("https" | "http")) {
return false;
}
let Some(host) = uri.host().map(str::to_ascii_lowercase) else {
return false;
};
HOSTS.iter().any(|allowed| {
host == *allowed
|| host
.strip_suffix(allowed)
.is_some_and(|sub| sub.ends_with('.'))
})
}
/// Resolve `t.co` short URLs.
async fn tco_resolver(
State(state): State<AppState>,
@@ -160,3 +191,26 @@ fn percent_decode(url: &str) -> String {
.unwrap_or_else(|_| url.into())
.into_owned()
}
#[cfg(test)]
mod tests {
use super::is_redirectable;
#[test]
fn redirect_allows_twitter_youtube_and_reddit() {
assert!(is_redirectable("https://x.com/SpaceX/status/1"));
assert!(is_redirectable("https://mobile.twitter.com/home"));
assert!(is_redirectable("http://www.youtube.com/watch?v=abc"));
assert!(is_redirectable("https://youtu.be/abc"));
assert!(is_redirectable("https://old.reddit.com/r/spacex"));
}
#[test]
fn redirect_rejects_other_targets() {
assert!(!is_redirectable("https://evil.com"));
assert!(!is_redirectable("https://twitter.com.evil.com"));
assert!(!is_redirectable("javascript:alert(1)"));
assert!(!is_redirectable("https://evil.com#@x.com"));
assert!(!is_redirectable("/local"));
}
}
+380
View File
@@ -0,0 +1,380 @@
use std::fmt;
/// Unicode regional indicator symbols start at U+1F1E6 (letter A).
const REGIONAL_INDICATOR_A: u32 = 0x1F1E6;
/// An ISO 3166-1 alpha-2 code, rendered as the regional indicator pair that
/// browsers and system fonts draw as a flag.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Flag([u8; 2]);
impl fmt::Display for Flag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for letter in self.0 {
let indicator = char::from_u32(REGIONAL_INDICATOR_A + u32::from(letter - b'A'))
.expect("an alpha-2 letter offsets into the regional indicator block");
write!(f, "{indicator}")?;
}
Ok(())
}
}
/// ISO 3166-1 names and their common-name variants, lowercased and sorted by
/// code point so `flag` can binary search them.
static COUNTRIES: &[(&str, [u8; 2])] = &[
("afghanistan", *b"AF"),
("albania", *b"AL"),
("algeria", *b"DZ"),
("american samoa", *b"AS"),
("andorra", *b"AD"),
("angola", *b"AO"),
("anguilla", *b"AI"),
("antarctica", *b"AQ"),
("antigua and barbuda", *b"AG"),
("argentina", *b"AR"),
("armenia", *b"AM"),
("aruba", *b"AW"),
("australia", *b"AU"),
("austria", *b"AT"),
("azerbaijan", *b"AZ"),
("bahamas", *b"BS"),
("bahrain", *b"BH"),
("bangladesh", *b"BD"),
("barbados", *b"BB"),
("belarus", *b"BY"),
("belgium", *b"BE"),
("belize", *b"BZ"),
("benin", *b"BJ"),
("bermuda", *b"BM"),
("bhutan", *b"BT"),
("bolivia", *b"BO"),
("bolivia, plurinational state of", *b"BO"),
("bonaire, sint eustatius and saba", *b"BQ"),
("bosnia and herzegovina", *b"BA"),
("botswana", *b"BW"),
("bouvet island", *b"BV"),
("brazil", *b"BR"),
("british indian ocean territory", *b"IO"),
("brunei darussalam", *b"BN"),
("bulgaria", *b"BG"),
("burkina faso", *b"BF"),
("burundi", *b"BI"),
("cabo verde", *b"CV"),
("cambodia", *b"KH"),
("cameroon", *b"CM"),
("canada", *b"CA"),
("cayman islands", *b"KY"),
("central african republic", *b"CF"),
("chad", *b"TD"),
("chile", *b"CL"),
("china", *b"CN"),
("christmas island", *b"CX"),
("cocos (keeling) islands", *b"CC"),
("colombia", *b"CO"),
("comoros", *b"KM"),
("congo", *b"CG"),
("congo, the democratic republic of the", *b"CD"),
("cook islands", *b"CK"),
("costa rica", *b"CR"),
("croatia", *b"HR"),
("cuba", *b"CU"),
("curaçao", *b"CW"),
("cyprus", *b"CY"),
("czechia", *b"CZ"),
("côte d'ivoire", *b"CI"),
("denmark", *b"DK"),
("djibouti", *b"DJ"),
("dominica", *b"DM"),
("dominican republic", *b"DO"),
("ecuador", *b"EC"),
("egypt", *b"EG"),
("el salvador", *b"SV"),
("equatorial guinea", *b"GQ"),
("eritrea", *b"ER"),
("estonia", *b"EE"),
("eswatini", *b"SZ"),
("ethiopia", *b"ET"),
("falkland islands (malvinas)", *b"FK"),
("faroe islands", *b"FO"),
("fiji", *b"FJ"),
("finland", *b"FI"),
("france", *b"FR"),
("french guiana", *b"GF"),
("french polynesia", *b"PF"),
("french southern territories", *b"TF"),
("gabon", *b"GA"),
("gambia", *b"GM"),
("georgia", *b"GE"),
("germany", *b"DE"),
("ghana", *b"GH"),
("gibraltar", *b"GI"),
("greece", *b"GR"),
("greenland", *b"GL"),
("grenada", *b"GD"),
("guadeloupe", *b"GP"),
("guam", *b"GU"),
("guatemala", *b"GT"),
("guernsey", *b"GG"),
("guinea", *b"GN"),
("guinea-bissau", *b"GW"),
("guyana", *b"GY"),
("haiti", *b"HT"),
("heard island and mcdonald islands", *b"HM"),
("holy see (vatican city state)", *b"VA"),
("honduras", *b"HN"),
("hong kong", *b"HK"),
("hungary", *b"HU"),
("iceland", *b"IS"),
("india", *b"IN"),
("indonesia", *b"ID"),
("iran", *b"IR"),
("iran, islamic republic of", *b"IR"),
("iraq", *b"IQ"),
("ireland", *b"IE"),
("isle of man", *b"IM"),
("israel", *b"IL"),
("italy", *b"IT"),
("jamaica", *b"JM"),
("japan", *b"JP"),
("jersey", *b"JE"),
("jordan", *b"JO"),
("kazakhstan", *b"KZ"),
("kenya", *b"KE"),
("kiribati", *b"KI"),
("korea, democratic people's republic of", *b"KP"),
("korea, republic of", *b"KR"),
("kuwait", *b"KW"),
("kyrgyzstan", *b"KG"),
("lao people's democratic republic", *b"LA"),
("laos", *b"LA"),
("latvia", *b"LV"),
("lebanon", *b"LB"),
("lesotho", *b"LS"),
("liberia", *b"LR"),
("libya", *b"LY"),
("liechtenstein", *b"LI"),
("lithuania", *b"LT"),
("luxembourg", *b"LU"),
("macao", *b"MO"),
("madagascar", *b"MG"),
("malawi", *b"MW"),
("malaysia", *b"MY"),
("maldives", *b"MV"),
("mali", *b"ML"),
("malta", *b"MT"),
("marshall islands", *b"MH"),
("martinique", *b"MQ"),
("mauritania", *b"MR"),
("mauritius", *b"MU"),
("mayotte", *b"YT"),
("mexico", *b"MX"),
("micronesia, federated states of", *b"FM"),
("moldova", *b"MD"),
("moldova, republic of", *b"MD"),
("monaco", *b"MC"),
("mongolia", *b"MN"),
("montenegro", *b"ME"),
("montserrat", *b"MS"),
("morocco", *b"MA"),
("mozambique", *b"MZ"),
("myanmar", *b"MM"),
("namibia", *b"NA"),
("nauru", *b"NR"),
("nepal", *b"NP"),
("netherlands", *b"NL"),
("new caledonia", *b"NC"),
("new zealand", *b"NZ"),
("nicaragua", *b"NI"),
("niger", *b"NE"),
("nigeria", *b"NG"),
("niue", *b"NU"),
("norfolk island", *b"NF"),
("north korea", *b"KP"),
("north macedonia", *b"MK"),
("northern mariana islands", *b"MP"),
("norway", *b"NO"),
("oman", *b"OM"),
("pakistan", *b"PK"),
("palau", *b"PW"),
("palestine, state of", *b"PS"),
("panama", *b"PA"),
("papua new guinea", *b"PG"),
("paraguay", *b"PY"),
("peru", *b"PE"),
("philippines", *b"PH"),
("pitcairn", *b"PN"),
("poland", *b"PL"),
("portugal", *b"PT"),
("puerto rico", *b"PR"),
("qatar", *b"QA"),
("romania", *b"RO"),
("russian federation", *b"RU"),
("rwanda", *b"RW"),
("réunion", *b"RE"),
("saint barthélemy", *b"BL"),
("saint helena, ascension and tristan da cunha", *b"SH"),
("saint kitts and nevis", *b"KN"),
("saint lucia", *b"LC"),
("saint martin (french part)", *b"MF"),
("saint pierre and miquelon", *b"PM"),
("saint vincent and the grenadines", *b"VC"),
("samoa", *b"WS"),
("san marino", *b"SM"),
("sao tome and principe", *b"ST"),
("saudi arabia", *b"SA"),
("senegal", *b"SN"),
("serbia", *b"RS"),
("seychelles", *b"SC"),
("sierra leone", *b"SL"),
("singapore", *b"SG"),
("sint maarten (dutch part)", *b"SX"),
("slovakia", *b"SK"),
("slovenia", *b"SI"),
("solomon islands", *b"SB"),
("somalia", *b"SO"),
("south africa", *b"ZA"),
("south georgia and the south sandwich islands", *b"GS"),
("south korea", *b"KR"),
("south sudan", *b"SS"),
("spain", *b"ES"),
("sri lanka", *b"LK"),
("sudan", *b"SD"),
("suriname", *b"SR"),
("svalbard and jan mayen", *b"SJ"),
("sweden", *b"SE"),
("switzerland", *b"CH"),
("syria", *b"SY"),
("syrian arab republic", *b"SY"),
("taiwan", *b"TW"),
("taiwan, province of china", *b"TW"),
("tajikistan", *b"TJ"),
("tanzania", *b"TZ"),
("tanzania, united republic of", *b"TZ"),
("thailand", *b"TH"),
("timor-leste", *b"TL"),
("togo", *b"TG"),
("tokelau", *b"TK"),
("tonga", *b"TO"),
("trinidad and tobago", *b"TT"),
("tunisia", *b"TN"),
("turkmenistan", *b"TM"),
("turks and caicos islands", *b"TC"),
("tuvalu", *b"TV"),
("türkiye", *b"TR"),
("uganda", *b"UG"),
("ukraine", *b"UA"),
("united arab emirates", *b"AE"),
("united kingdom", *b"GB"),
("united states", *b"US"),
("united states minor outlying islands", *b"UM"),
("uruguay", *b"UY"),
("uzbekistan", *b"UZ"),
("vanuatu", *b"VU"),
("venezuela", *b"VE"),
("venezuela, bolivarian republic of", *b"VE"),
("viet nam", *b"VN"),
("vietnam", *b"VN"),
("virgin islands, british", *b"VG"),
("virgin islands, u.s.", *b"VI"),
("wallis and futuna", *b"WF"),
("western sahara", *b"EH"),
("yemen", *b"YE"),
("zambia", *b"ZM"),
("zimbabwe", *b"ZW"),
("åland islands", *b"AX"),
];
/// Colloquial English names for countries whose ISO name is a formal or
/// inverted one, plus the abbreviations a caller may pass by hand. Sorted
/// alongside `COUNTRIES`.
static ALIASES: &[(&str, [u8; 2])] = &[
("cape verde", *b"CV"),
("czech republic", *b"CZ"),
("democratic republic of the congo", *b"CD"),
("east timor", *b"TL"),
("great britain", *b"GB"),
("ivory coast", *b"CI"),
("korea", *b"KR"),
("macau", *b"MO"),
("palestine", *b"PS"),
("republic of korea", *b"KR"),
("republic of the congo", *b"CG"),
("russia", *b"RU"),
("swaziland", *b"SZ"),
("the netherlands", *b"NL"),
("turkey", *b"TR"),
("uk", *b"GB"),
("united states of america", *b"US"),
("usa", *b"US"),
("vatican city", *b"VA"),
];
/// Expand X's ambiguous Korea label for display.
pub fn display_name(country: &str) -> &str {
if country.trim().eq_ignore_ascii_case("korea") {
"South Korea"
} else {
country
}
}
/// Resolve an English country name, or a bare alpha-2 code, to its flag.
/// X also reports broad regions such as "Europe", which have no flag.
pub fn flag(country: &str) -> Option<Flag> {
let country = country.trim();
let lowered = || country.chars().flat_map(char::to_lowercase);
let by_name = |table: &[(&str, [u8; 2])]| {
table
.binary_search_by(|entry| entry.0.chars().cmp(lowered()))
.ok()
.map(|index| Flag(table[index].1))
};
by_name(COUNTRIES).or_else(|| by_name(ALIASES)).or_else(|| {
let code = <[u8; 2]>::try_from(country.as_bytes())
.ok()?
.map(|letter| letter.to_ascii_uppercase());
COUNTRIES
.iter()
.any(|&(_, known)| known == code)
.then_some(Flag(code))
})
}
#[cfg(test)]
mod tests {
use super::flag;
#[test]
fn x_location_strings_resolve() {
for (location, expected) in [
("United States", Some("🇺🇸")),
("United Kingdom", Some("🇬🇧")),
(" UK ", Some("🇬🇧")),
("USA", Some("🇺🇸")),
("br", Some("🇧🇷")),
("Czechia", Some("🇨🇿")),
("Czech Republic", Some("🇨🇿")),
("Russia", Some("🇷🇺")),
("TÜRKIYE", Some("🇹🇷")),
("South Korea", Some("🇰🇷")),
(" KOREA ", Some("🇰🇷")),
("North Korea", Some("🇰🇵")),
("Vietnam", Some("🇻🇳")),
("Côte d'Ivoire", Some("🇨🇮")),
("Ivory Coast", Some("🇨🇮")),
("Åland Islands", Some("🇦🇽")),
("Europe", None),
("North America", None),
("Southeast Asia", None),
("", None),
("ZZ", None),
("1A", None),
("🇺🇸", None),
] {
let found = flag(location).map(|found| found.to_string());
assert_eq!(found.as_deref(), expected, "{location}");
}
}
}
+31 -10
View File
@@ -3,9 +3,9 @@
//! Converts @mentions, #hashtags, and URLs into clickable HTML links.
//!
//! Twitter's API returns `full_text` with HTML entities already escaped
//! (`&amp;`, `&lt;`, `&gt;`, etc.). Text between entities is passed through
//! as-is since it's already HTML-safe. Only entity *attributes* (URLs,
//! display names) need escaping because they come from our own processing.
//! (`&amp;`, `&lt;`, `&gt;`, etc.), so existing entities are kept. Raw `<` and
//! `>` are escaped anyway because `note_tweet` text and bios are not
//! guaranteed to arrive escaped.
use std::{borrow::Cow, fmt::Write as _, sync::LazyLock};
@@ -47,7 +47,7 @@ pub fn expand_entities_for_x(text: &str, entities: &[Entity]) -> String {
fn expand_entities_with_target(text: &str, entities: &[Entity], link_to_x: bool) -> String {
if entities.is_empty() {
return text.to_owned();
return escape_tags(text).into_owned();
}
// Sort entities by start position
@@ -81,13 +81,13 @@ fn expand_entities_with_target(text: &str, entities: &[Entity], link_to_x: bool)
continue;
};
// Add text before this entity (already HTML-escaped by Twitter)
// Add text before this entity. Existing entities stay; raw tags do not.
if start_char > last_end_char {
result.push_str(&text[last_end_byte..start_byte]);
result.push_str(&escape_tags(&text[last_end_byte..start_byte]));
}
// Add the entity as an HTML link
let entity_text = &text[start_byte..end_byte];
let entity_text = escape_tags(&text[start_byte..end_byte]);
match entity.kind {
EntityKind::Mention => {
if link_to_x {
@@ -147,11 +147,11 @@ fn expand_entities_with_target(text: &str, entities: &[Entity], link_to_x: bool)
last_end_char = end_char;
}
// Add remaining text (already HTML-escaped by Twitter)
// Add remaining text, escaping any raw tags in it.
if last_end_char < char_count
&& let Some(last_end_byte) = char_to_byte_index(text, last_end_char)
{
result.push_str(&text[last_end_byte..]);
result.push_str(&escape_tags(&text[last_end_byte..]));
}
result
@@ -162,7 +162,8 @@ fn expand_entities_with_target(text: &str, entities: &[Entity], link_to_x: bool)
/// Operates directly on Twitter's pre-escaped text. The `HASHTAG_RE` pattern
/// uses `[^\w&;]` to avoid matching HTML entities like `&#x27;`.
pub fn expand_with_regex(text: &str) -> String {
let result = MENTION_RE.replace_all(text, r#"$1<a href="/$2">@$2</a>"#);
let escaped = escape_tags(text);
let result = MENTION_RE.replace_all(&escaped, r#"$1<a href="/$2">@$2</a>"#);
let result = HASHTAG_RE.replace_all(&result, r#"$1<a href="/search?q=%23$3">$2$3</a>"#);
result.into_owned()
}
@@ -191,6 +192,14 @@ pub fn html_escape(text: &str) -> Cow<'_, str> {
Cow::Owned(result)
}
/// Escape only `<` and `>`, leaving X's existing `&amp;`-style entities intact.
pub fn escape_tags(text: &str) -> Cow<'_, str> {
if !text.contains(['<', '>']) {
return Cow::Borrowed(text);
}
Cow::Owned(text.replace('<', "&lt;").replace('>', "&gt;"))
}
/// URL encode a string (delegates to formatters).
fn url_encode(text: &str) -> String {
super::formatters::url_encode(text)
@@ -351,4 +360,16 @@ mod tests {
let result = expand_entities("A &amp; B &lt; C &gt; D", &[]);
assert_eq!(result, "A &amp; B &lt; C &gt; D");
}
#[test]
fn raw_angle_brackets_are_escaped() {
let result = expand_entities("hi <script>alert(1)</script>", &[]);
assert!(!result.contains("<script>"));
assert!(result.contains("&lt;script&gt;alert(1)&lt;/script&gt;"));
let fallback = expand_with_regex("see <b>@user</b>");
assert!(fallback.contains("&lt;b&gt;"));
assert!(fallback.contains(r#"<a href="/user">@user</a>"#));
assert!(fallback.contains("&lt;/b&gt;"));
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod country;
pub mod entity_expander;
pub mod formatters;
pub mod hmac;
+3 -3
View File
@@ -5,8 +5,8 @@ use regex::Regex;
use crate::{config::Config, types::Prefs};
pub const STYLE_CSS: &str = "/css/style.css?teapawt9";
pub const CLEAN_CSS: &str = "/css/clean.css?teapawt35";
pub const STYLE_CSS: &str = "/css/style.css?teapawt10";
pub const CLEAN_CSS: &str = "/css/clean.css?teapawt36";
pub const FONTELLO_CSS: &str = "/css/fontello.css";
/// Builder for rendering a full page layout.
@@ -255,7 +255,7 @@ impl<'a> PageLayout<'a> {
// Scripts in <head> with defer
script src="/js/htmx.min.js" defer="" {}
script src="/js/webmcp.js" defer="" {}
script src="/js/site.js" defer="" {}
script src="/js/site.js?teapawt=1" defer="" {}
script src="/js/threadContext.js" defer="" {}
script src="/js/imageLightbox.js" defer="" {}
@if infinite_scroll {
+13 -10
View File
@@ -4,7 +4,10 @@ use super::timeline::{render_timeline_tabs, render_timeline_with_pinned_and_pref
use crate::{
config::Config,
types::{GalleryPhoto, Prefs, Profile, TimelineKind, User},
utils::formatters,
utils::{
country::{display_name as country_display_name, flag as country_flag},
formatters,
},
views::renderutils::{
gen_img, get_avatar_class, icon, link_user, parse_location, render_bio_html, short_link,
verified_icon,
@@ -198,18 +201,18 @@ fn render_account_context(user: &User) -> Markup {
return Markup::default();
}
let country = country_display_name(&user.account_based_in);
html! {
details class="account-context-trigger" {
summary class="account-signal" title="Show X-reported account information" {
span class="account-signal-info" aria-hidden="true" { "i" }
span class="sr-only" { "Account based in" }
strong {
@if user.account_based_in.is_empty() {
"Unknown"
} @else {
(user.account_based_in)
}
@if let Some(flag) = country_flag(country) {
span class="account-signal-flag" role="img" aria-label=(country) { (flag) }
} @else {
span class="icon-location" role="img"
aria-label=(if country.is_empty() { "Account information" } else { country }) {}
}
span class="sr-only" { "Account based in" }
@if user.location_accurate == Some(false) {
span class="account-signal-warning"
title="X says this location may be affected by a proxy or VPN" { "!" }
@@ -224,7 +227,7 @@ fn render_account_context(user: &User) -> Markup {
@if !user.account_based_in.is_empty() {
div class="account-context-row" {
dt { "Account based in" }
dd { (user.account_based_in) }
dd { (country) }
}
}
@if !user.connection_source.is_empty() {
+2 -2
View File
@@ -3,7 +3,7 @@ use std::{collections::HashSet, fmt::Write as _};
use crate::{
config::Config,
types::{List, Tweet, Tweets, User},
utils::{formatters, html_escape},
utils::{escape_tags, formatters, html_escape},
views::{
layout::strip_html,
renderutils::{community_note_to_html, tweet_link},
@@ -285,7 +285,7 @@ fn render_rss_tweet(tweet: &Tweet, config: &Config, proxy: bool) -> String {
let url_prefix = config.url_prefix();
// Use replaceUrls with absolute URLs
let text = formatters::replace_urls_abs(&tweet.text, config, url_prefix);
let text = formatters::replace_urls_abs(&escape_tags(&tweet.text), config, url_prefix);
let mut body = format!("<p>{}</p>", text.replace('\n', "<br>\n"));
// Photos
+11 -5
View File
@@ -6,7 +6,7 @@ use crate::{
Card, CardKind, Chain, Entity, Photo, Poll, Prefs, Tweet, TweetStats, User,
VIDEO_CACHE_LIMIT_MESSAGE, Video,
},
utils::{entity_expander::expand_with_regex, expand_entities, formatters},
utils::{country, entity_expander::expand_with_regex, expand_entities, formatters},
views::renderutils::{
button_referer, gen_img, get_avatar_class, get_medium_pic, get_small_pic, icon, link_user,
parse_location, proxy_media, render_community_note, verified_icon,
@@ -815,18 +815,24 @@ fn render_tweet_account_signal(user: &User) -> Markup {
return Markup::default();
}
let country = country::display_name(&user.account_based_in);
html! {
details class="tweet-account-trigger" {
summary class="tweet-account-location" title="Show X-reported account information" {
span class="account-signal-dot" {}
(user.account_based_in)
summary class="tweet-account-location"
title=(format!("Show X-reported account information: {country}")) {
@if let Some(flag) = country::flag(country) {
span class="account-signal-flag" role="img" aria-label=(country) { (flag) }
} @else {
span class="icon-location" role="img" aria-label=(country) {}
}
@if user.location_accurate == Some(false) {
span class="tweet-account-warning"
title="X says this location may be affected by a proxy or VPN" { "!" }
}
}
div class="tweet-account-popover" {
strong { (user.account_based_in) }
strong { (country) }
@if !user.connection_source.is_empty() {
span { "Connected via " (user.connection_source) }
}