Improve security
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
use std::future::{ready, Ready};
|
||||
|
||||
use actix_web::dev::Payload;
|
||||
use actix_web::{error::ErrorUnauthorized, Error, FromRequest, HttpMessage, HttpRequest};
|
||||
|
||||
/// The user id of the caller, as established by the auth middleware after
|
||||
/// verifying the `user-token` header. Extracting this (instead of trusting a
|
||||
/// client-supplied `user_id` in the path/body) is the source of truth for
|
||||
/// "who is making this request".
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct AuthUser(pub i32);
|
||||
|
||||
impl FromRequest for AuthUser {
|
||||
type Error = Error;
|
||||
type Future = Ready<Result<Self, Self::Error>>;
|
||||
|
||||
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
|
||||
match req.extensions().get::<AuthUser>() {
|
||||
Some(auth_user) => ready(Ok(*auth_user)),
|
||||
None => ready(Err(ErrorUnauthorized("missing authenticated user"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
-11
@@ -2,17 +2,32 @@ extern crate hmac;
|
||||
extern crate jwt;
|
||||
extern crate sha2;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::env;
|
||||
|
||||
use actix_web::HttpRequest;
|
||||
use chrono::{Duration, Utc};
|
||||
use dotenv::dotenv;
|
||||
use hmac::{Hmac, Mac};
|
||||
use jwt::{Header, SignWithKey, Token, VerifyWithKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
|
||||
/// How long a freshly issued token remains valid for.
|
||||
const TOKEN_LIFETIME_HOURS: i64 = 24;
|
||||
|
||||
pub struct JwtToken {
|
||||
pub user_id: i32,
|
||||
pub token_version: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Claims {
|
||||
user_id: i32,
|
||||
/// Must match `users.token_version` for the token to be accepted; bumping
|
||||
/// the column (e.g. on logout) revokes every token issued before that point.
|
||||
tv: i32,
|
||||
/// Unix timestamp after which the token is rejected, independent of signature validity.
|
||||
exp: i64,
|
||||
}
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
@@ -25,11 +40,14 @@ fn signing_key() -> HmacSha256 {
|
||||
}
|
||||
|
||||
impl JwtToken {
|
||||
pub fn encode(user_id: i32) -> String {
|
||||
pub fn encode(user_id: i32, token_version: i32) -> String {
|
||||
let key: HmacSha256 = signing_key();
|
||||
let mut claims = BTreeMap::new();
|
||||
claims.insert("user_id", user_id);
|
||||
// Signing a simple map of claims with a valid HMAC key cannot fail.
|
||||
let claims = Claims {
|
||||
user_id,
|
||||
tv: token_version,
|
||||
exp: (Utc::now() + Duration::hours(TOKEN_LIFETIME_HOURS)).timestamp(),
|
||||
};
|
||||
// Signing claims with a valid HMAC key cannot fail.
|
||||
claims
|
||||
.sign_with_key(&key)
|
||||
.expect("signing claims with a valid HMAC key cannot fail")
|
||||
@@ -38,15 +56,18 @@ impl JwtToken {
|
||||
pub fn decode(encoded_token: String) -> Result<JwtToken, &'static str> {
|
||||
let key: HmacSha256 = signing_key();
|
||||
let token_str: &str = encoded_token.as_str();
|
||||
let token: Result<Token<Header, BTreeMap<String, i32>, jwt::Verified>, jwt::Error> =
|
||||
let token: Result<Token<Header, Claims, jwt::Verified>, jwt::Error> =
|
||||
VerifyWithKey::verify_with_key(token_str, &key);
|
||||
|
||||
match token {
|
||||
Ok(token) => {
|
||||
let _header = token.header();
|
||||
let claims = token.claims();
|
||||
if claims.exp < Utc::now().timestamp() {
|
||||
return Err("token has expired");
|
||||
}
|
||||
Ok(JwtToken {
|
||||
user_id: claims["user_id"],
|
||||
user_id: claims.user_id,
|
||||
token_version: claims.tv,
|
||||
})
|
||||
}
|
||||
Err(_err) => Err("could not decode token"),
|
||||
@@ -68,14 +89,19 @@ impl JwtToken {
|
||||
#[cfg(test)]
|
||||
mod jwt_test {
|
||||
use actix_web::{http::header, test};
|
||||
use chrono::{Duration, Utc};
|
||||
use hmac::Hmac;
|
||||
use jwt::SignWithKey;
|
||||
use sha2::Sha256;
|
||||
|
||||
use super::JwtToken;
|
||||
use super::{Claims, JwtToken};
|
||||
|
||||
#[test]
|
||||
async fn encode_decode() {
|
||||
let encoded_token: String = JwtToken::encode(32);
|
||||
let encoded_token: String = JwtToken::encode(32, 0);
|
||||
let decoded_token: JwtToken = JwtToken::decode(encoded_token).unwrap();
|
||||
assert_eq!(32, decoded_token.user_id);
|
||||
assert_eq!(0, decoded_token.token_version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -88,9 +114,25 @@ mod jwt_test {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn decode_expired_token() {
|
||||
let key: Hmac<Sha256> = super::signing_key();
|
||||
let claims = Claims {
|
||||
user_id: 32,
|
||||
tv: 0,
|
||||
exp: (Utc::now() - Duration::hours(1)).timestamp(),
|
||||
};
|
||||
let expired_token: String = claims.sign_with_key(&key).unwrap();
|
||||
|
||||
match JwtToken::decode(expired_token) {
|
||||
Err(message) => assert_eq!(message, "token has expired"),
|
||||
_ => panic!("Expired token should not be accepted."),
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn decode_from_request_with_correct_token() {
|
||||
let encoded_token: String = JwtToken::encode(32);
|
||||
let encoded_token: String = JwtToken::encode(32, 0);
|
||||
let request = test::TestRequest::default()
|
||||
.insert_header(header::ContentType::json())
|
||||
.insert_header(("user-token", encoded_token))
|
||||
|
||||
+14
-8
@@ -1,12 +1,13 @@
|
||||
use actix_web::dev::ServiceRequest;
|
||||
pub mod extractor;
|
||||
pub mod jwt;
|
||||
pub mod processes;
|
||||
use crate::auth::processes::check_password;
|
||||
use crate::auth::processes::check_token;
|
||||
use crate::auth::processes::extract_header_token;
|
||||
|
||||
pub fn process_token(request: &ServiceRequest) -> Result<i32, &'static str> {
|
||||
match extract_header_token(request) {
|
||||
Ok(token) => check_password(token),
|
||||
Ok(token) => check_token(token),
|
||||
Err(message) => Err(message),
|
||||
}
|
||||
}
|
||||
@@ -16,19 +17,24 @@ mod mod_test {
|
||||
|
||||
use actix_web::test::TestRequest;
|
||||
|
||||
use super::{jwt::JwtToken, process_token};
|
||||
use super::process_token;
|
||||
use crate::auth::jwt::JwtToken;
|
||||
use crate::database::establish_connection;
|
||||
use crate::test_helpers::{delete_user, insert_user};
|
||||
|
||||
#[test]
|
||||
fn process_token_test() {
|
||||
let token = JwtToken::encode(32);
|
||||
let mut connection = establish_connection();
|
||||
let user = insert_user(&mut connection, "secret");
|
||||
|
||||
let token = JwtToken::encode(user.id, user.token_version);
|
||||
let request = TestRequest::delete()
|
||||
.insert_header(("user-token", token))
|
||||
.to_srv_request();
|
||||
|
||||
match process_token(&request) {
|
||||
Ok(user_id) => assert_eq!(32, user_id),
|
||||
Err(_) => panic!("process token failed"),
|
||||
}
|
||||
assert_eq!(Ok(user.id), process_token(&request));
|
||||
|
||||
delete_user(&mut connection, user.id);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
|
||||
+55
-24
@@ -1,22 +1,33 @@
|
||||
use super::jwt;
|
||||
use crate::database::establish_connection;
|
||||
use crate::models::user::rss_user::User;
|
||||
use crate::schema::users;
|
||||
use actix_web::dev::ServiceRequest;
|
||||
use diesel::prelude::*;
|
||||
|
||||
pub fn check_password(password: String) -> Result<i32, &'static str> {
|
||||
match jwt::JwtToken::decode(password) {
|
||||
Ok(token) => Ok(token.user_id),
|
||||
Err(message) => Err(message),
|
||||
/// Decodes the token and confirms it hasn't been revoked, i.e. its `token_version`
|
||||
/// still matches the one stored on the user (bumped on logout / password change).
|
||||
pub fn check_token(token: String) -> Result<i32, &'static str> {
|
||||
let decoded = jwt::JwtToken::decode(token)?;
|
||||
|
||||
let mut connection = establish_connection();
|
||||
let user: User = users::table
|
||||
.find(decoded.user_id)
|
||||
.first(&mut connection)
|
||||
.map_err(|_| "could not decode token")?;
|
||||
|
||||
if user.token_version != decoded.token_version {
|
||||
return Err("token has been revoked");
|
||||
}
|
||||
|
||||
Ok(decoded.user_id)
|
||||
}
|
||||
|
||||
pub fn extract_header_token(request: &ServiceRequest) -> Result<String, &'static str> {
|
||||
log::info!("Request: {:?}", request);
|
||||
match request.headers().get("user-token") {
|
||||
Some(token) => match token.to_str() {
|
||||
Ok(processed_password) => {
|
||||
log::info!("Token provided: {}", processed_password);
|
||||
Ok(String::from(processed_password))
|
||||
}
|
||||
Err(_processed_password) => Err("there was an error processing token"),
|
||||
Ok(processed_token) => Ok(String::from(processed_token)),
|
||||
Err(_) => Err("there was an error processing token"),
|
||||
},
|
||||
None => Err("there is no token"),
|
||||
}
|
||||
@@ -25,31 +36,51 @@ pub fn extract_header_token(request: &ServiceRequest) -> Result<String, &'static
|
||||
#[cfg(test)]
|
||||
mod processes_test {
|
||||
use actix_web::test::TestRequest;
|
||||
use diesel::prelude::*;
|
||||
|
||||
use crate::auth::jwt::JwtToken;
|
||||
use crate::database::establish_connection;
|
||||
use crate::test_helpers::{delete_user, insert_user};
|
||||
|
||||
use super::check_password;
|
||||
use super::check_token;
|
||||
|
||||
#[test]
|
||||
fn check_correct_password() {
|
||||
let password_string: String = JwtToken::encode(32);
|
||||
fn check_correct_token() {
|
||||
let mut connection = establish_connection();
|
||||
let user = insert_user(&mut connection, "secret");
|
||||
|
||||
let result = check_password(password_string);
|
||||
let token: String = JwtToken::encode(user.id, user.token_version);
|
||||
|
||||
match result {
|
||||
Ok(user_id) => assert_eq!(32, user_id),
|
||||
_ => panic!("Check correct password failed."),
|
||||
}
|
||||
let result = check_token(token);
|
||||
|
||||
assert_eq!(Ok(user.id), result);
|
||||
|
||||
delete_user(&mut connection, user.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incorrect_check_password() {
|
||||
let password: String = String::from("test");
|
||||
fn revoked_token_is_rejected() {
|
||||
let mut connection = establish_connection();
|
||||
let user = insert_user(&mut connection, "secret");
|
||||
|
||||
match check_password(password) {
|
||||
Err(message) => assert_eq!("could not decode token", message),
|
||||
_ => panic!("check password should not be able to be decoded"),
|
||||
}
|
||||
// Token signed with the user's current version, then the version is bumped
|
||||
// (as logout would do), which must invalidate the previously issued token.
|
||||
let token: String = JwtToken::encode(user.id, user.token_version);
|
||||
diesel::update(crate::schema::users::table.find(user.id))
|
||||
.set(crate::schema::users::token_version.eq(user.token_version + 1))
|
||||
.execute(&mut connection)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(Err("token has been revoked"), check_token(token));
|
||||
|
||||
delete_user(&mut connection, user.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incorrect_check_token() {
|
||||
let token: String = String::from("test");
|
||||
|
||||
assert_eq!(Err("could not decode token"), check_token(token));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+19
-15
@@ -3,7 +3,7 @@ extern crate dotenv;
|
||||
|
||||
use actix_cors::Cors;
|
||||
use actix_service::Service;
|
||||
use actix_web::{App, HttpResponse, HttpServer};
|
||||
use actix_web::{App, HttpMessage, HttpResponse, HttpServer};
|
||||
use dotenv::dotenv;
|
||||
use futures::future::{ok, Either};
|
||||
use std::env;
|
||||
@@ -37,28 +37,32 @@ async fn main() -> std::io::Result<()> {
|
||||
|
||||
App::new()
|
||||
.wrap_fn(|req, srv| {
|
||||
let mut passed: bool;
|
||||
let request_url: String = String::from(req.uri().path());
|
||||
|
||||
log::info!("Request Url: {}", request_url);
|
||||
if req.path().contains("/article/") {
|
||||
|
||||
// Only these endpoints are reachable without a valid token. Everything
|
||||
// else (in particular all `/article/*` endpoints) requires one.
|
||||
let is_public = matches!(
|
||||
request_url.as_str(),
|
||||
"/api/v1/auth/login" | "/api/v1/user/create"
|
||||
);
|
||||
|
||||
let passed = if is_public {
|
||||
true
|
||||
} else {
|
||||
match auth::process_token(&req) {
|
||||
Ok(user_id) => {
|
||||
log::info!("Authenticated user {} for {}", user_id, request_url);
|
||||
passed = true;
|
||||
req.extensions_mut().insert(auth::extractor::AuthUser(user_id));
|
||||
true
|
||||
}
|
||||
Err(message) => {
|
||||
log::warn!("Rejected request to {}: {}", request_url, message);
|
||||
false
|
||||
}
|
||||
Err(_message) => passed = false,
|
||||
}
|
||||
} else {
|
||||
log::warn!("No auth check done.");
|
||||
passed = true;
|
||||
}
|
||||
|
||||
if req.path().contains("user/create") {
|
||||
passed = true;
|
||||
}
|
||||
|
||||
log::info!("passed: {:?}", passed);
|
||||
};
|
||||
|
||||
let end_result = match passed {
|
||||
true => Either::Left(srv.call(req)),
|
||||
|
||||
@@ -6,6 +6,19 @@ use uuid::Uuid;
|
||||
|
||||
use crate::schema::users;
|
||||
|
||||
pub const MIN_PASSWORD_LENGTH: usize = 6;
|
||||
|
||||
/// Rejects empty/trivial passwords. Kept as a standalone function so callers
|
||||
/// (e.g. the user-creation handler) can return a `400` for this case
|
||||
/// specifically, rather than the `500` that `NewUser::new`'s `anyhow::Result`
|
||||
/// would otherwise map to.
|
||||
pub fn validate_password(password: &str) -> Result<(), &'static str> {
|
||||
if password.trim().len() < MIN_PASSWORD_LENGTH {
|
||||
return Err("password must be at least 6 characters long");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Insertable, Clone)]
|
||||
#[diesel(table_name=users)]
|
||||
pub struct NewUser {
|
||||
@@ -17,6 +30,7 @@ pub struct NewUser {
|
||||
|
||||
impl NewUser {
|
||||
pub fn new(username: String, email: String, password: String) -> anyhow::Result<NewUser> {
|
||||
validate_password(&password).map_err(anyhow::Error::msg)?;
|
||||
let hashed_password: String = hash(password.as_str(), DEFAULT_COST)?;
|
||||
let uuid = Uuid::new_v4();
|
||||
Ok(NewUser {
|
||||
|
||||
@@ -14,6 +14,7 @@ pub struct User {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub unique_id: String,
|
||||
pub token_version: i32,
|
||||
}
|
||||
|
||||
impl User {
|
||||
|
||||
+43
-5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¤t).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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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('>', ">")
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -28,6 +28,7 @@ diesel::table! {
|
||||
email -> Varchar,
|
||||
password -> Varchar,
|
||||
unique_id -> Varchar,
|
||||
token_version -> Int4,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ pub async fn login(credentials: web::Json<Login>) -> Result<HttpResponse, AppErr
|
||||
match user.clone().verify(password)? {
|
||||
true => {
|
||||
log::info!("verified password successfully for user {}", user.id);
|
||||
let token: String = JwtToken::encode(user.clone().id);
|
||||
let token: String = JwtToken::encode(user.id, user.token_version);
|
||||
Ok(HttpResponse::Ok()
|
||||
.insert_header(("token", token))
|
||||
.insert_header(("user_id", user.id))
|
||||
|
||||
@@ -1,25 +1,62 @@
|
||||
use actix_web::HttpResponse;
|
||||
use diesel::prelude::*;
|
||||
|
||||
// JWT auth is stateless and there is no token blacklist, so logging out is
|
||||
// purely a client-side action (discarding the stored token). This endpoint
|
||||
// exists so the frontend has something to call and gets a clean response.
|
||||
pub async fn logout() -> HttpResponse {
|
||||
HttpResponse::Ok().finish()
|
||||
use crate::auth::extractor::AuthUser;
|
||||
use crate::database::establish_connection;
|
||||
use crate::error::AppError;
|
||||
use crate::schema::users;
|
||||
|
||||
/// Invalidates every token previously issued for this user by bumping
|
||||
/// `token_version` — the auth middleware rejects tokens whose `tv` claim no
|
||||
/// longer matches the stored value.
|
||||
pub async fn logout(auth_user: AuthUser) -> Result<HttpResponse, AppError> {
|
||||
let mut connection = establish_connection();
|
||||
|
||||
diesel::update(users::table.find(auth_user.0))
|
||||
.set(users::token_version.eq(users::token_version + 1))
|
||||
.execute(&mut connection)?;
|
||||
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
}
|
||||
|
||||
#[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::logout;
|
||||
use crate::auth::extractor::AuthUser;
|
||||
use crate::database::establish_connection;
|
||||
use crate::models::user::rss_user::User;
|
||||
use crate::schema::users;
|
||||
use crate::test_helpers::{delete_user, insert_user};
|
||||
|
||||
#[actix_web::test]
|
||||
async fn logout_returns_ok() {
|
||||
let app = test::init_service(App::new().route("/logout", web::post().to(logout))).await;
|
||||
async fn logout_bumps_token_version() {
|
||||
let mut connection = establish_connection();
|
||||
let user = insert_user(&mut connection, "secret");
|
||||
let user_id = user.id;
|
||||
let initial_version = user.token_version;
|
||||
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.wrap_fn(move |req, srv| {
|
||||
req.extensions_mut().insert(AuthUser(user_id));
|
||||
srv.call(req)
|
||||
})
|
||||
.route("/logout", web::post().to(logout)),
|
||||
)
|
||||
.await;
|
||||
let req = test::TestRequest::post().uri("/logout").to_request();
|
||||
let resp = test::call_service(&app, req).await;
|
||||
|
||||
assert_eq!(StatusCode::OK, resp.status());
|
||||
|
||||
let updated: User = users::table.find(user_id).first(&mut connection).unwrap();
|
||||
assert_eq!(initial_version + 1, updated.token_version);
|
||||
|
||||
delete_user(&mut connection, user_id);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-3
@@ -1,3 +1,4 @@
|
||||
use actix_governor::{Governor, GovernorConfigBuilder};
|
||||
use actix_web::web;
|
||||
use actix_web::web::ServiceConfig;
|
||||
|
||||
@@ -12,9 +13,19 @@ pub fn auth_factory(app: &mut ServiceConfig) {
|
||||
backend: true,
|
||||
};
|
||||
|
||||
app.route(
|
||||
&base_path.define(String::from("/login")),
|
||||
web::post().to(login::login),
|
||||
// Login is the only unauthenticated endpoint that checks a password, so
|
||||
// it's the only one worth rate-limiting against brute-force/credential
|
||||
// stuffing. One request every 2s with a burst of 5 per IP.
|
||||
let login_rate_limit = GovernorConfigBuilder::default()
|
||||
.seconds_per_request(2)
|
||||
.burst_size(5)
|
||||
.finish()
|
||||
.expect("valid governor rate-limit config");
|
||||
|
||||
app.service(
|
||||
web::resource(base_path.define(String::from("/login")))
|
||||
.wrap(Governor::new(&login_rate_limit))
|
||||
.route(web::post().to(login::login)),
|
||||
);
|
||||
app.route(
|
||||
&base_path.define(String::from("/logout")),
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::database::establish_connection;
|
||||
use crate::diesel;
|
||||
use crate::error::AppError;
|
||||
use crate::json_serialization::new_user::NewUserSchema;
|
||||
use crate::models::user::new_user::NewUser;
|
||||
use crate::models::user::new_user::{validate_password, NewUser};
|
||||
use crate::schema::users;
|
||||
use actix_web::{web, HttpResponse};
|
||||
use diesel::prelude::*;
|
||||
@@ -13,6 +13,10 @@ pub async fn create(new_user: web::Json<NewUserSchema>) -> Result<HttpResponse,
|
||||
let email: String = new_user.email.clone();
|
||||
let new_password: String = new_user.password.clone();
|
||||
|
||||
if let Err(message) = validate_password(&new_password) {
|
||||
return Ok(HttpResponse::BadRequest().body(message));
|
||||
}
|
||||
|
||||
let new_user = NewUser::new(name, email, new_password)?;
|
||||
|
||||
let insert_result = diesel::insert_into(users::table)
|
||||
@@ -61,6 +65,24 @@ mod tests {
|
||||
.ok();
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn create_fails_for_short_password() {
|
||||
let suffix = unique_suffix();
|
||||
|
||||
let app = test::init_service(App::new().route("/create", web::post().to(create))).await;
|
||||
let req = test::TestRequest::post()
|
||||
.uri("/create")
|
||||
.set_json(serde_json::json!({
|
||||
"name": format!("short_pw_{suffix}"),
|
||||
"email": format!("short_{suffix}@example.test"),
|
||||
"password": "abc"
|
||||
}))
|
||||
.to_request();
|
||||
let resp = test::call_service(&app, req).await;
|
||||
|
||||
assert_eq!(StatusCode::BAD_REQUEST, resp.status());
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn create_fails_for_duplicate_user() {
|
||||
let mut connection = establish_connection();
|
||||
|
||||
Reference in New Issue
Block a user