Improve security

This commit is contained in:
2026-06-12 19:22:07 +02:00
parent 0820ce6ef7
commit b457b8abaa
31 changed files with 1266 additions and 169 deletions
+43 -5
View File
@@ -2,13 +2,17 @@ use actix_web::{web, HttpResponse};
use diesel::RunQueryDsl;
use crate::{
database::establish_connection, json_serialization::new_feed::NewFeedSchema,
models::feed::new_feed::NewFeed, schema::feed,
auth::extractor::AuthUser, database::establish_connection,
json_serialization::new_feed::NewFeedSchema, models::feed::new_feed::NewFeed, schema::feed,
};
use super::feeds;
pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse {
pub async fn add(new_feed: web::Json<NewFeedSchema>, auth_user: AuthUser) -> HttpResponse {
if auth_user.0 != new_feed.user_id {
return HttpResponse::Forbidden().finish();
}
let mut connection = establish_connection();
let title: String = new_feed.title.clone();
let url: String = new_feed.url.clone();
@@ -45,15 +49,25 @@ pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse {
#[cfg(test)]
mod tests {
use actix_service::Service;
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use actix_web::{test, web, App, HttpMessage};
use super::add;
use crate::auth::extractor::AuthUser;
use crate::test_helpers::unique_suffix;
#[actix_web::test]
async fn add_fails_for_unfetchable_feed_url() {
let app = test::init_service(App::new().route("/add", web::post().to(add))).await;
let app = test::init_service(
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(1));
srv.call(req)
})
.route("/add", web::post().to(add)),
)
.await;
let req = test::TestRequest::post()
.uri("/add")
.set_json(serde_json::json!({
@@ -66,4 +80,28 @@ mod tests {
assert_eq!(StatusCode::NOT_FOUND, resp.status());
}
#[actix_web::test]
async fn add_rejects_feed_for_another_user() {
let app = test::init_service(
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(1));
srv.call(req)
})
.route("/add", web::post().to(add)),
)
.await;
let req = test::TestRequest::post()
.uri("/add")
.set_json(serde_json::json!({
"title": "Someone else's feed",
"url": format!("https://example.test/feed/{}", unique_suffix()),
"user_id": 2
}))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::FORBIDDEN, resp.status());
}
}
+69 -10
View File
@@ -2,21 +2,25 @@ use actix_web::{web, HttpResponse};
use diesel::prelude::*;
use crate::{
auth::extractor::AuthUser,
database::establish_connection,
schema::{feed, feed_item},
};
pub async fn delete_feed(path: web::Path<i32>) -> HttpResponse {
pub async fn delete_feed(path: web::Path<i32>, auth_user: AuthUser) -> HttpResponse {
let feed_id = path.into_inner();
let mut connection = establish_connection();
let exists = feed::table
let owner: Option<i32> = feed::table
.find(feed_id)
.count()
.get_result::<i64>(&mut connection)
.unwrap_or(0);
.select(feed::user_id)
.first(&mut connection)
.optional()
.unwrap_or(None);
if exists == 0 {
// Treat "doesn't exist" and "not yours" the same, so callers can't probe
// for other users' feed ids.
if owner != Some(auth_user.0) {
return HttpResponse::NotFound().finish();
}
@@ -33,11 +37,13 @@ pub async fn delete_feed(path: web::Path<i32>) -> HttpResponse {
#[cfg(test)]
mod tests {
use actix_service::Service;
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use actix_web::{test, web, App, HttpMessage};
use diesel::prelude::*;
use super::delete_feed;
use crate::auth::extractor::AuthUser;
use crate::database::establish_connection;
use crate::schema::{feed, feed_item};
use crate::test_helpers::{
@@ -51,8 +57,14 @@ mod tests {
let f = insert_feed(&mut connection, user.id);
let item = insert_feed_item(&mut connection, f.id, false);
let user_id = user.id;
let app = test::init_service(
App::new().route("/feed/{feed_id}", web::delete().to(delete_feed)),
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(user_id));
srv.call(req)
})
.route("/feed/{feed_id}", web::delete().to(delete_feed)),
)
.await;
let req = test::TestRequest::delete()
@@ -82,7 +94,12 @@ mod tests {
#[actix_web::test]
async fn delete_feed_returns_404_for_nonexistent_feed() {
let app = test::init_service(
App::new().route("/feed/{feed_id}", web::delete().to(delete_feed)),
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(1));
srv.call(req)
})
.route("/feed/{feed_id}", web::delete().to(delete_feed)),
)
.await;
let req = test::TestRequest::delete()
@@ -100,8 +117,14 @@ mod tests {
let feed_a = insert_feed(&mut connection, user.id);
let feed_b = insert_feed(&mut connection, user.id);
let user_id = user.id;
let app = test::init_service(
App::new().route("/feed/{feed_id}", web::delete().to(delete_feed)),
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(user_id));
srv.call(req)
})
.route("/feed/{feed_id}", web::delete().to(delete_feed)),
)
.await;
let req = test::TestRequest::delete()
@@ -121,4 +144,40 @@ mod tests {
cleanup_feed(&mut connection, feed_b.id);
delete_user(&mut connection, user.id);
}
#[actix_web::test]
async fn delete_feed_rejects_other_users_feed() {
let mut connection = establish_connection();
let user_a = insert_user(&mut connection, "secret");
let user_b = insert_user(&mut connection, "secret");
let feed_b = insert_feed(&mut connection, user_b.id);
let user_a_id = user_a.id;
let app = test::init_service(
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(user_a_id));
srv.call(req)
})
.route("/feed/{feed_id}", web::delete().to(delete_feed)),
)
.await;
let req = test::TestRequest::delete()
.uri(&format!("/feed/{}", feed_b.id))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::NOT_FOUND, resp.status());
let feed_b_exists: i64 = feed::table
.filter(feed::id.eq(feed_b.id))
.count()
.get_result(&mut connection)
.unwrap();
assert_eq!(1, feed_b_exists);
cleanup_feed(&mut connection, feed_b.id);
delete_user(&mut connection, user_a.id);
delete_user(&mut connection, user_b.id);
}
}
+5 -4
View File
@@ -1,9 +1,10 @@
use std::error::Error;
use rss::Channel;
pub async fn get_feed(feed: &str) -> Result<Channel, Box<dyn Error>> {
let content = reqwest::get(feed).await?.bytes().await?;
use super::net::safe_fetch;
use crate::error::AppError;
pub async fn get_feed(feed: &str) -> Result<Channel, AppError> {
let content = safe_fetch(feed).await?.bytes().await?;
let channel = Channel::read_from(&content[..])?;
log::debug!("{:?}", channel);
Ok(channel)
+51 -6
View File
@@ -1,3 +1,4 @@
use crate::auth::extractor::AuthUser;
use crate::error::AppError;
use crate::json_serialization::user::JsonUser;
use crate::models::feed::rss_feed::Feed;
@@ -10,17 +11,25 @@ use crate::{
schema::feed::{self, user_id},
schema::feed_item,
};
use actix_web::{web, HttpRequest, Responder};
use actix_web::{web, HttpRequest, HttpResponse, Responder};
use chrono::Local;
use diesel::prelude::*;
use super::structs::article::Article;
pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> Result<impl Responder, AppError> {
pub async fn get(
path: web::Path<JsonUser>,
req: HttpRequest,
auth_user: AuthUser,
) -> Result<HttpResponse, AppError> {
let request = req.clone();
let req_user_id = path.user_id;
log::info!("Received user_id: {}", req_user_id);
if auth_user.0 != req_user_id {
return Ok(HttpResponse::Forbidden().finish());
}
let mut connection: diesel::PgConnection = establish_connection();
let feeds: Vec<Feed> = feed::table
.filter(user_id.eq(req_user_id))
@@ -69,15 +78,17 @@ pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> Result<impl Res
feeds: feed_aggregates,
};
Ok(articles.respond_to(&request))
Ok(articles.respond_to(&request).map_into_boxed_body())
}
#[cfg(test)]
mod tests {
use actix_service::Service;
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use actix_web::{test, web, App, HttpMessage};
use super::get;
use crate::auth::extractor::AuthUser;
use crate::database::establish_connection;
use crate::test_helpers::{
delete_feed, delete_feed_item, delete_user, insert_feed, insert_feed_item, insert_user,
@@ -91,8 +102,16 @@ mod tests {
let unread = insert_feed_item(&mut connection, feed.id, false);
let read = insert_feed_item(&mut connection, feed.id, true);
let app =
test::init_service(App::new().route("/get/{user_id}", web::get().to(get))).await;
let user_id = user.id;
let app = test::init_service(
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(user_id));
srv.call(req)
})
.route("/get/{user_id}", web::get().to(get)),
)
.await;
let req = test::TestRequest::get()
.uri(&format!("/get/{}", user.id))
.to_request();
@@ -111,4 +130,30 @@ mod tests {
delete_feed(&mut connection, feed.id);
delete_user(&mut connection, user.id);
}
#[actix_web::test]
async fn get_rejects_requests_for_another_user() {
let mut connection = establish_connection();
let user_a = insert_user(&mut connection, "secret");
let user_b = insert_user(&mut connection, "secret");
let app = test::init_service(
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(user_a.id));
srv.call(req)
})
.route("/get/{user_id}", web::get().to(get)),
)
.await;
let req = test::TestRequest::get()
.uri(&format!("/get/{}", user_b.id))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::FORBIDDEN, resp.status());
delete_user(&mut connection, user_a.id);
delete_user(&mut connection, user_b.id);
}
}
+38 -13
View File
@@ -1,7 +1,8 @@
use actix_web::{web, HttpRequest, Responder};
use actix_web::{web, HttpRequest, HttpResponse, Responder};
use diesel::prelude::*;
use crate::{
auth::extractor::AuthUser,
database::establish_connection,
error::AppError,
json_serialization::feed_info::{FeedInfo, FeedInfoList},
@@ -12,10 +13,15 @@ use crate::{
pub async fn list_feeds(
path: web::Path<JsonUser>,
req: HttpRequest,
) -> Result<impl Responder, AppError> {
auth_user: AuthUser,
) -> Result<HttpResponse, AppError> {
let request = req.clone();
let req_user_id = path.user_id;
if auth_user.0 != req_user_id {
return Ok(HttpResponse::Forbidden().finish());
}
let mut connection = establish_connection();
let feeds = feed::table
.filter(user_id.eq(req_user_id))
@@ -27,15 +33,19 @@ pub async fn list_feeds(
.map(|(id, title, url)| FeedInfo { id, title, url })
.collect();
Ok(FeedInfoList { feeds: feed_list }.respond_to(&request))
Ok(FeedInfoList { feeds: feed_list }
.respond_to(&request)
.map_into_boxed_body())
}
#[cfg(test)]
mod tests {
use actix_service::Service;
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use actix_web::{test, web, App, HttpMessage};
use super::list_feeds;
use crate::auth::extractor::AuthUser;
use crate::database::establish_connection;
use crate::test_helpers::{delete_feed, delete_user, insert_feed, insert_user};
@@ -45,8 +55,14 @@ mod tests {
let user = insert_user(&mut connection, "secret");
let feed = insert_feed(&mut connection, user.id);
let user_id = user.id;
let app = test::init_service(
App::new().route("/feeds/{user_id}", web::get().to(list_feeds)),
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(user_id));
srv.call(req)
})
.route("/feeds/{user_id}", web::get().to(list_feeds)),
)
.await;
let req = test::TestRequest::get()
@@ -69,8 +85,14 @@ mod tests {
let mut connection = establish_connection();
let user = insert_user(&mut connection, "secret");
let user_id = user.id;
let app = test::init_service(
App::new().route("/feeds/{user_id}", web::get().to(list_feeds)),
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(user_id));
srv.call(req)
})
.route("/feeds/{user_id}", web::get().to(list_feeds)),
)
.await;
let req = test::TestRequest::get()
@@ -87,25 +109,28 @@ mod tests {
}
#[actix_web::test]
async fn list_feeds_does_not_return_other_users_feeds() {
async fn list_feeds_rejects_requests_for_another_user() {
let mut connection = establish_connection();
let user_a = insert_user(&mut connection, "secret");
let user_b = insert_user(&mut connection, "secret");
let feed_b = insert_feed(&mut connection, user_b.id);
let user_a_id = user_a.id;
let app = test::init_service(
App::new().route("/feeds/{user_id}", web::get().to(list_feeds)),
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(user_a_id));
srv.call(req)
})
.route("/feeds/{user_id}", web::get().to(list_feeds)),
)
.await;
let req = test::TestRequest::get()
.uri(&format!("/feeds/{}", user_a.id))
.uri(&format!("/feeds/{}", user_b.id))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::OK, resp.status());
let body = test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(!body_str.contains(&feed_b.title));
assert_eq!(StatusCode::FORBIDDEN, resp.status());
delete_feed(&mut connection, feed_b.id);
delete_user(&mut connection, user_a.id);
+78 -15
View File
@@ -1,28 +1,37 @@
use crate::auth::extractor::AuthUser;
use crate::error::AppError;
use crate::schema::feed_item::{id, read};
use crate::{
database::establish_connection, json_serialization::read_feed_item::ReadItem,
models::feed_item::rss_feed_item::FeedItem, schema::feed_item,
database::establish_connection,
json_serialization::read_feed_item::ReadItem,
models::feed_item::rss_feed_item::FeedItem,
schema::{feed, feed_item},
};
use actix_web::{web, HttpRequest, HttpResponse, Responder};
use diesel::RunQueryDsl;
use diesel::{ExpressionMethods, QueryDsl};
use diesel::{ExpressionMethods, OptionalExtension, QueryDsl};
pub async fn mark_read(
_req: HttpRequest,
path: web::Path<ReadItem>,
auth_user: AuthUser,
) -> Result<impl Responder, AppError> {
let mut connection = establish_connection();
log::info!("Id: {}", path.id);
let mut feed_items: Vec<FeedItem> = feed_item::table
// Join through to `feed` so we can confirm the item belongs to the caller
// before mutating it. "Doesn't exist" and "not yours" both return 404.
let owned_item: Option<(FeedItem, i32)> = feed_item::table
.inner_join(feed::table)
.filter(id.eq(path.id))
.load::<FeedItem>(&mut connection)?;
.select((feed_item::all_columns, feed::user_id))
.first(&mut connection)
.optional()?;
if feed_items.len() != 1 {
return Ok(HttpResponse::NotFound().finish());
}
let feed_item: FeedItem = feed_items.remove(0);
let feed_item = match owned_item {
Some((feed_item, owner_id)) if owner_id == auth_user.0 => feed_item,
_ => return Ok(HttpResponse::NotFound().finish()),
};
let result = diesel::update(&feed_item)
.set(read.eq(true))
@@ -35,11 +44,13 @@ pub async fn mark_read(
#[cfg(test)]
mod tests {
use actix_service::Service;
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use actix_web::{test, web, App, HttpMessage};
use diesel::prelude::*;
use super::mark_read;
use crate::auth::extractor::AuthUser;
use crate::database::establish_connection;
use crate::models::feed_item::rss_feed_item::FeedItem;
use crate::schema::feed_item;
@@ -54,8 +65,16 @@ mod tests {
let feed = insert_feed(&mut connection, user.id);
let item = insert_feed_item(&mut connection, feed.id, false);
let app =
test::init_service(App::new().route("/read/{id}", web::put().to(mark_read))).await;
let user_id = user.id;
let app = test::init_service(
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(user_id));
srv.call(req)
})
.route("/read/{id}", web::put().to(mark_read)),
)
.await;
let req = test::TestRequest::put()
.uri(&format!("/read/{}", item.id))
.to_request();
@@ -76,11 +95,55 @@ mod tests {
#[actix_web::test]
async fn mark_read_returns_not_found_for_unknown_id() {
let app =
test::init_service(App::new().route("/read/{id}", web::put().to(mark_read))).await;
let app = test::init_service(
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(1));
srv.call(req)
})
.route("/read/{id}", web::put().to(mark_read)),
)
.await;
let req = test::TestRequest::put().uri("/read/999999999").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::NOT_FOUND, resp.status());
}
#[actix_web::test]
async fn mark_read_rejects_other_users_item() {
let mut connection = establish_connection();
let user_a = insert_user(&mut connection, "secret");
let user_b = insert_user(&mut connection, "secret");
let feed_b = insert_feed(&mut connection, user_b.id);
let item_b = insert_feed_item(&mut connection, feed_b.id, false);
let user_a_id = user_a.id;
let app = test::init_service(
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(user_a_id));
srv.call(req)
})
.route("/read/{id}", web::put().to(mark_read)),
)
.await;
let req = test::TestRequest::put()
.uri(&format!("/read/{}", item_b.id))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::NOT_FOUND, resp.status());
let updated: FeedItem = feed_item::table
.find(item_b.id)
.first(&mut connection)
.unwrap();
assert!(!updated.read);
delete_feed_item(&mut connection, item_b.id);
delete_feed(&mut connection, feed_b.id);
delete_user(&mut connection, user_a.id);
delete_user(&mut connection, user_b.id);
}
}
+1
View File
@@ -7,6 +7,7 @@ pub mod feeds;
mod get;
mod list_feeds;
mod mark_read;
pub mod net;
mod read;
mod scraper;
pub mod structs;
+145
View File
@@ -0,0 +1,145 @@
use std::net::IpAddr;
use anyhow::bail;
use reqwest::{redirect::Policy, Client, Response, Url};
use tokio::net::lookup_host;
use crate::error::AppError;
// Outbound requests for feed/article URLs are driven by user input (feed URLs,
// "read" links). Without these checks a user could point the server at
// internal services or cloud metadata endpoints (e.g. http://169.254.169.254/)
// and have it fetch them on their behalf (SSRF).
pub async fn safe_fetch(url: &str) -> Result<Response, AppError> {
safe_fetch_inner(url).await.map_err(AppError::from)
}
// Redirects are validated and followed manually (rather than via
// `redirect::Policy::default()`) so each hop's resolved address is checked
// against `is_globally_routable` before it's fetched — otherwise an allowed
// host could redirect to an internal address and bypass the checks below.
const MAX_REDIRECTS: u8 = 5;
async fn safe_fetch_inner(url: &str) -> anyhow::Result<Response> {
let client = Client::builder().redirect(Policy::none()).build()?;
let mut current = Url::parse(url)?;
for _ in 0..=MAX_REDIRECTS {
check_url_is_safe(&current).await?;
let response = client.get(current.clone()).send().await?;
if response.status().is_redirection() {
let location = response
.headers()
.get(reqwest::header::LOCATION)
.ok_or_else(|| {
anyhow::anyhow!("redirect response from {} has no Location header", current)
})?
.to_str()?;
current = current.join(location)?;
continue;
}
return Ok(response);
}
bail!("refusing to fetch {}: too many redirects", url);
}
async fn check_url_is_safe(url: &Url) -> anyhow::Result<()> {
if url.scheme() != "http" && url.scheme() != "https" {
bail!("refusing to fetch {}: unsupported URL scheme", url);
}
let host = url
.host_str()
.ok_or_else(|| anyhow::anyhow!("refusing to fetch {}: URL has no host", url))?;
let port = url.port_or_known_default().unwrap_or(80);
let mut resolved_any = false;
for addr in lookup_host((host, port)).await? {
resolved_any = true;
if !is_globally_routable(addr.ip()) {
bail!(
"refusing to fetch {}: resolves to non-public address {}",
url,
addr.ip()
);
}
}
if !resolved_any {
bail!("refusing to fetch {}: host did not resolve to any address", url);
}
Ok(())
}
fn is_globally_routable(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
!(v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_broadcast()
|| v4.is_multicast())
}
IpAddr::V6(v6) => {
if let Some(v4) = v6.to_ipv4_mapped() {
return is_globally_routable(IpAddr::V4(v4));
}
let segments = v6.segments();
let is_unique_local = (segments[0] & 0xfe00) == 0xfc00; // fc00::/7
let is_unicast_link_local = (segments[0] & 0xffc0) == 0xfe80; // fe80::/10
!(v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
|| is_unique_local
|| is_unicast_link_local)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_loopback_and_private_addresses() {
assert!(!is_globally_routable("127.0.0.1".parse().unwrap()));
assert!(!is_globally_routable("10.0.0.5".parse().unwrap()));
assert!(!is_globally_routable("192.168.1.1".parse().unwrap()));
assert!(!is_globally_routable("169.254.169.254".parse().unwrap()));
assert!(!is_globally_routable("::1".parse().unwrap()));
assert!(!is_globally_routable("fc00::1".parse().unwrap()));
assert!(!is_globally_routable("fe80::1".parse().unwrap()));
assert!(!is_globally_routable("::ffff:127.0.0.1".parse().unwrap()));
}
#[test]
fn allows_public_addresses() {
assert!(is_globally_routable("93.184.216.34".parse().unwrap()));
assert!(is_globally_routable("2606:2800:220:1:248:1893:25c8:1946".parse().unwrap()));
}
#[actix_web::test]
async fn rejects_unsupported_schemes() {
let result = safe_fetch("ftp://example.test/file").await;
assert!(result.is_err());
}
#[actix_web::test]
async fn rejects_loopback_urls() {
let result = safe_fetch("http://127.0.0.1:8001/").await;
assert!(result.is_err());
}
#[actix_web::test]
async fn rejects_link_local_metadata_url() {
let result = safe_fetch("http://169.254.169.254/latest/meta-data/").await;
assert!(result.is_err());
}
}
+5 -4
View File
@@ -1,8 +1,9 @@
use reqwest::Error;
use super::super::net::safe_fetch;
use crate::error::AppError;
// Do a request for the given URL, with a minimum time between requests
// to avoid overloading the server.
pub async fn do_throttled_request(url: &str) -> Result<String, Error> {
let response = reqwest::get(url).await?;
response.text().await
pub async fn do_throttled_request(url: &str) -> Result<String, AppError> {
let response = safe_fetch(url).await?;
Ok(response.text().await?)
}
+106 -7
View File
@@ -1,4 +1,5 @@
use super::feeds;
use crate::auth::extractor::AuthUser;
use crate::error::AppError;
use crate::json_serialization::user::JsonUser;
use crate::models::feed::rss_feed::Feed;
@@ -12,12 +13,13 @@ use crate::{
feed_item,
},
};
use actix_web::{web, HttpRequest, HttpResponse, Responder};
use actix_web::{web, HttpRequest, HttpResponse};
use chrono::{DateTime, Local, NaiveDateTime};
use dateparser::parse;
use diesel::prelude::*;
use rss::Item;
use scraper::{Html, Selector};
use std::collections::{HashMap, HashSet};
fn get_date(date_str: &str) -> Result<NaiveDateTime, chrono::ParseError> {
if let Ok(result) = parse(date_str) {
@@ -47,6 +49,20 @@ fn escape_html_attr(value: &str) -> String {
.replace('>', "&gt;")
}
// Feed-supplied `<img>` markup is rendered as-is (via `v-html`) in the frontend,
// so strip everything except a harmless image tag before it's stored — in
// particular event handlers like `onerror`/`onload` that a malicious feed
// could use for XSS.
fn sanitize_img_html(html: &str) -> String {
let allowed_attributes = HashSet::from(["src", "alt", "title"]);
ammonia::Builder::default()
.tags(HashSet::from(["img"]))
.tag_attributes(HashMap::from([("img", allowed_attributes)]))
.clean(html)
.to_string()
}
// Some feeds (e.g. Deutsche Welle) don't embed an <img> in the item content at
// all — they carry the article image as an RSS <enclosure> instead. Build an
// <img> tag from it so those feeds get a preview image too.
@@ -107,12 +123,12 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) -> a
let selector_img = Selector::parse("img").expect("\"img\" is a valid CSS selector");
match frag.select(&selector_img).find(image_src_is_resolvable) {
Some(image) => {
content.push_str(&image.html());
content.push_str(&sanitize_img_html(&image.html()));
content.push_str("<br>");
}
None => {
if let Some(image_html) = enclosure_image_html(&item) {
content.push_str(&image_html);
content.push_str(&sanitize_img_html(&image_html));
content.push_str("<br>");
}
}
@@ -155,11 +171,16 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) -> a
pub async fn sync(
_req: HttpRequest,
data: web::Json<JsonUser>,
) -> Result<impl Responder, AppError> {
let mut connection: diesel::PgConnection = establish_connection();
auth_user: AuthUser,
) -> Result<HttpResponse, AppError> {
let req_user_id: i32 = data.user_id;
if auth_user.0 != req_user_id {
return Ok(HttpResponse::Forbidden().finish());
}
let mut connection: diesel::PgConnection = establish_connection();
let feeds: Vec<Feed> = feed::table
.filter(user_id.eq(req_user_id))
.load::<Feed>(&mut connection)?;
@@ -183,7 +204,7 @@ pub async fn sync(
}
}
Ok(HttpResponse::Ok())
Ok(HttpResponse::Ok().finish())
}
#[cfg(test)]
@@ -281,6 +302,28 @@ mod tests {
);
}
#[test]
fn sanitize_img_html_strips_event_handlers() {
let sanitized = sanitize_img_html(r#"<img src="x" onerror="alert(1)">"#);
assert!(!sanitized.contains("onerror"));
assert!(!sanitized.contains("alert"));
assert!(sanitized.contains(r#"src="x""#));
}
#[test]
fn sanitize_img_html_keeps_only_allowed_attributes() {
let sanitized = sanitize_img_html(
r#"<img src="https://example.test/img.jpg" alt="desc" title="t" style="display:none" class="evil">"#,
);
assert!(sanitized.contains(r#"src="https://example.test/img.jpg""#));
assert!(sanitized.contains(r#"alt="desc""#));
assert!(sanitized.contains(r#"title="t""#));
assert!(!sanitized.contains("style"));
assert!(!sanitized.contains("class"));
}
#[actix_web::test]
async fn create_feed_item_inserts_articles_older_than_two_weeks() {
let mut connection = establish_connection();
@@ -397,6 +440,62 @@ mod tests {
.ok();
}
#[actix_web::test]
async fn create_feed_item_strips_onerror_from_feed_image() {
let mut connection = establish_connection();
let suffix = unique_suffix();
let new_user = NewUser::new(
format!("xss_test_{suffix}"),
format!("xss_{suffix}@example.test"),
"secret".to_string(),
)
.unwrap();
let user: User = diesel::insert_into(users::table)
.values(&new_user)
.get_result(&mut connection)
.unwrap();
let new_feed = NewFeed::new(
format!("XSS test feed {suffix}"),
format!("https://example.test/feed/{suffix}"),
user.id,
);
let feed: Feed = diesel::insert_into(feed::table)
.values(&new_feed)
.get_result(&mut connection)
.unwrap();
let mut item = Item::default();
item.set_title(Some(format!("XSS article {suffix}")));
item.set_link(Some(format!("https://example.test/xss/{suffix}")));
item.set_content(Some(
r#"<img src="https://example.test/real.jpg" onerror="alert(1)"><p>text</p>"#
.to_string(),
));
create_feed_item(item, &feed, &mut connection).unwrap();
let stored: FeedItem = feed_item::table
.filter(feed_id.eq(feed.id))
.first(&mut connection)
.unwrap();
assert!(!stored.content.contains("onerror"));
assert!(!stored.content.contains("alert"));
assert!(stored.content.contains(r#"src="https://example.test/real.jpg""#));
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
.execute(&mut connection)
.ok();
diesel::delete(feed::table.filter(feed::id.eq(feed.id)))
.execute(&mut connection)
.ok();
diesel::delete(users::table.filter(users::id.eq(user.id)))
.execute(&mut connection)
.ok();
}
#[actix_web::test]
async fn create_feed_item_strips_social_sharing_widget() {
let mut connection = establish_connection();