diff --git a/.gitignore b/.gitignore index 8ca5d19..07599d8 100755 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /target .env .claude +CLAUDE.md +LEARNINGS.md diff --git a/Cargo.lock b/Cargo.lock index 0809332..60e395a 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,6 +34,18 @@ dependencies = [ "smallvec", ] +[[package]] +name = "actix-governor" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a7ffa43d3e1e92518355ffbc82c146b5f0fe24fba87f19f405270da7a7b3c1e" +dependencies = [ + "actix-http", + "actix-web", + "futures", + "governor", +] + [[package]] name = "actix-http" version = "3.12.1" @@ -52,7 +64,7 @@ dependencies = [ "derive_more", "encoding_rs", "flate2", - "foldhash", + "foldhash 0.1.5", "futures-core", "h2 0.3.27", "http 0.2.12", @@ -167,7 +179,7 @@ dependencies = [ "cookie", "derive_more", "encoding_rs", - "foldhash", + "foldhash 0.1.5", "futures-core", "futures-util", "impl-more", @@ -231,6 +243,25 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ammonia" +version = "4.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" +dependencies = [ + "cssparser 0.35.0", + "html5ever 0.35.0", + "maplit", + "tendril 0.4.3", + "url", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -612,6 +643,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -631,19 +668,42 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "cssparser" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +dependencies = [ + "cssparser-macros 0.6.1", + "dtoa-short", + "itoa", + "phf 0.11.3", + "smallvec", +] + [[package]] name = "cssparser" version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" dependencies = [ - "cssparser-macros", + "cssparser-macros 0.7.0", "dtoa-short", "itoa", - "phf", + "phf 0.13.1", "smallvec", ] +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "cssparser-macros" version = "0.7.0" @@ -724,6 +784,20 @@ dependencies = [ "syn", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "dateparser" version = "0.3.1" @@ -1029,6 +1103,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1044,6 +1124,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.32" @@ -1115,6 +1205,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.32" @@ -1192,6 +1288,29 @@ dependencies = [ "wasip3", ] +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.4", + "smallvec", + "spinning_top", + "web-time", +] + [[package]] name = "h2" version = "0.3.27" @@ -1230,13 +1349,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -1260,6 +1396,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "html5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +dependencies = [ + "log", + "markup5ever 0.35.0", + "match_token", +] + [[package]] name = "html5ever" version = "0.39.0" @@ -1267,7 +1414,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" dependencies = [ "log", - "markup5ever", + "markup5ever 0.39.0", ] [[package]] @@ -1752,6 +1899,29 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markup5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +dependencies = [ + "log", + "tendril 0.4.3", + "web_atoms 0.1.3", +] + [[package]] name = "markup5ever" version = "0.39.0" @@ -1759,8 +1929,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" dependencies = [ "log", - "tendril", - "web_atoms", + "tendril 0.5.0", + "web_atoms 0.2.4", +] + +[[package]] +name = "match_token" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1824,6 +2005,12 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + [[package]] name = "num-conv" version = "0.2.2" @@ -1886,25 +2073,55 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.13.1", + "phf_shared 0.13.1", "serde", ] +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf_codegen" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.6", ] [[package]] @@ -1914,7 +2131,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1923,13 +2153,22 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.13.1" @@ -2026,6 +2265,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quick-xml" version = "0.39.4" @@ -2113,6 +2367,15 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.4" @@ -2144,6 +2407,12 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.5" @@ -2159,6 +2428,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2275,9 +2553,11 @@ name = "rss-reader" version = "0.1.0" dependencies = [ "actix-cors", + "actix-governor", "actix-rt", "actix-service", "actix-web", + "ammonia", "anyhow", "bcrypt", "chrono", @@ -2433,13 +2713,13 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2" dependencies = [ - "cssparser", + "cssparser 0.37.0", "ego-tree", "getopts", - "html5ever", + "html5ever 0.39.0", "precomputed-hash", "selectors", - "tendril", + "tendril 0.5.0", ] [[package]] @@ -2472,12 +2752,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" dependencies = [ "bitflags", - "cssparser", + "cssparser 0.37.0", "derive_more", "log", "new_debug_unreachable", - "phf", - "phf_codegen", + "phf 0.13.1", + "phf_codegen 0.13.1", "precomputed-hash", "rustc-hash", "servo_arc", @@ -2661,12 +2941,34 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + [[package]] name = "string_cache" version = "0.9.0" @@ -2675,18 +2977,30 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.13.1", "precomputed-hash", ] +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + [[package]] name = "string_cache_codegen" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", ] @@ -2755,6 +3069,17 @@ dependencies = [ "libc", ] +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "tendril" version = "0.5.0" @@ -3248,16 +3573,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +dependencies = [ + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache 0.8.9", + "string_cache_codegen 0.5.4", +] + [[package]] name = "web_atoms" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" dependencies = [ - "phf", - "phf_codegen", - "string_cache", - "string_cache_codegen", + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache 0.9.0", + "string_cache_codegen 0.6.1", ] [[package]] @@ -3269,6 +3606,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -3278,6 +3631,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 674161f..446acf0 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,8 @@ scraper = "0.27" actix-cors = "0.7" chrono = { version = "0.4.45", features = ["serde"] } dateparser = "0.3" +ammonia = "4.1.2" +actix-governor = "0.10.0" [dependencies.serde_json] version = "1.0.150" diff --git a/README.md b/README.md index a7554f4..4804943 100644 --- a/README.md +++ b/README.md @@ -82,10 +82,12 @@ Create a user, then log in through the UI at `http://localhost:5173`: ```sh curl -X POST -H "Content-Type: application/json" \ - -d '{"name": "mace", "email": "you@example.com", "password": "secret"}' \ + -d '{"name": "mace", "email": "you@example.com", "password": "secret1"}' \ http://localhost:8001/api/v1/user/create ``` +Passwords must be at least 6 characters. + ### Useful commands during development ```sh @@ -110,6 +112,30 @@ docker exec -it rss-postgres psql -d rss -U admin --- +## Security notes + +- **Sessions**: login returns a JWT (`token` header) valid for 24 hours. Logging out + (`POST /api/v1/auth/logout`, requires the current token) bumps the user's + `token_version`, which immediately invalidates *all* outstanding tokens for that + account — there's no per-session revocation, so logging out on one device logs out + every device. +- **Login rate limiting**: `POST /api/v1/auth/login` is limited to a burst of 5 requests + per IP, replenishing one every 2 seconds (`actix-governor`). +- **Outbound fetches**: feed syncs and the article-reader endpoint only fetch + `http`/`https` URLs that resolve to public IP addresses (no loopback/private/ + link-local, e.g. `127.0.0.1` or the `169.254.169.254` cloud metadata address). + Redirects are followed (up to 5 hops), but each redirect target is checked against the + same rules before being fetched, so a redirect can't be used to reach an internal + address. +- **Stored feed content**: `` tags from synced feed content are sanitized + (`ammonia`) down to `src`/`alt`/`title` before being stored, since they're later + rendered with `v-html` in the frontend. +- **If `JWT_SECRET` or `POSTGRES_PASSWORD` are ever leaked** (e.g. committed to git), + rotate them in `.env` and restart the backend — rotating `JWT_SECRET` invalidates every + outstanding token as a side effect. + +--- + ## Production setup (Docker) The whole stack — Postgres, backend, and frontend — runs via Docker Compose. The frontend is built as a static Vue bundle and served by nginx, which also reverse-proxies `/api/` to the backend container. diff --git a/migrations/2026-06-12-000000_add_token_version_to_users/down.sql b/migrations/2026-06-12-000000_add_token_version_to_users/down.sql new file mode 100644 index 0000000..d805bfe --- /dev/null +++ b/migrations/2026-06-12-000000_add_token_version_to_users/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE users +DROP COLUMN token_version; diff --git a/migrations/2026-06-12-000000_add_token_version_to_users/up.sql b/migrations/2026-06-12-000000_add_token_version_to_users/up.sql new file mode 100644 index 0000000..6d5225d --- /dev/null +++ b/migrations/2026-06-12-000000_add_token_version_to_users/up.sql @@ -0,0 +1,3 @@ +-- Your SQL goes here +ALTER TABLE users +ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0; diff --git a/src/auth/extractor.rs b/src/auth/extractor.rs new file mode 100644 index 0000000..3081257 --- /dev/null +++ b/src/auth/extractor.rs @@ -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>; + + fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future { + match req.extensions().get::() { + Some(auth_user) => ready(Ok(*auth_user)), + None => ready(Err(ErrorUnauthorized("missing authenticated user"))), + } + } +} diff --git a/src/auth/jwt.rs b/src/auth/jwt.rs index a59d3e1..b02f304 100755 --- a/src/auth/jwt.rs +++ b/src/auth/jwt.rs @@ -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; @@ -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 { let key: HmacSha256 = signing_key(); let token_str: &str = encoded_token.as_str(); - let token: Result, jwt::Verified>, jwt::Error> = + let token: Result, 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 = 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)) diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 57e4fde..fd72c49 100755 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -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 { 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] diff --git a/src/auth/processes.rs b/src/auth/processes.rs index 55a57d2..f2bb714 100755 --- a/src/auth/processes.rs +++ b/src/auth/processes.rs @@ -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 { - 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 { + 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 { - 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 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] diff --git a/src/main.rs b/src/main.rs index 60a6e38..3514cd8 100755 --- a/src/main.rs +++ b/src/main.rs @@ -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)), diff --git a/src/models/user/new_user.rs b/src/models/user/new_user.rs index 0fc1723..7a42445 100755 --- a/src/models/user/new_user.rs +++ b/src/models/user/new_user.rs @@ -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 { + 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 { diff --git a/src/models/user/rss_user.rs b/src/models/user/rss_user.rs index 7d7e61f..33dc6cb 100755 --- a/src/models/user/rss_user.rs +++ b/src/models/user/rss_user.rs @@ -14,6 +14,7 @@ pub struct User { pub email: String, pub password: String, pub unique_id: String, + pub token_version: i32, } impl User { diff --git a/src/reader/add.rs b/src/reader/add.rs index b90e170..2f2e83a 100644 --- a/src/reader/add.rs +++ b/src/reader/add.rs @@ -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) -> HttpResponse { +pub async fn add(new_feed: web::Json, 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) -> 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()); + } } diff --git a/src/reader/delete_feed.rs b/src/reader/delete_feed.rs index 1005d83..82cc3ef 100644 --- a/src/reader/delete_feed.rs +++ b/src/reader/delete_feed.rs @@ -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) -> HttpResponse { +pub async fn delete_feed(path: web::Path, auth_user: AuthUser) -> HttpResponse { let feed_id = path.into_inner(); let mut connection = establish_connection(); - let exists = feed::table + let owner: Option = feed::table .find(feed_id) - .count() - .get_result::(&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) -> 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); + } } diff --git a/src/reader/feeds.rs b/src/reader/feeds.rs index 5157e51..0bca3ef 100755 --- a/src/reader/feeds.rs +++ b/src/reader/feeds.rs @@ -1,9 +1,10 @@ -use std::error::Error; - use rss::Channel; -pub async fn get_feed(feed: &str) -> Result> { - 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 { + let content = safe_fetch(feed).await?.bytes().await?; let channel = Channel::read_from(&content[..])?; log::debug!("{:?}", channel); Ok(channel) diff --git a/src/reader/get.rs b/src/reader/get.rs index 7383ba5..bc70476 100755 --- a/src/reader/get.rs +++ b/src/reader/get.rs @@ -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, req: HttpRequest) -> Result { +pub async fn get( + path: web::Path, + req: HttpRequest, + auth_user: AuthUser, +) -> Result { 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::table .filter(user_id.eq(req_user_id)) @@ -69,15 +78,17 @@ pub async fn get(path: web::Path, req: HttpRequest) -> Result, req: HttpRequest, -) -> Result { + auth_user: AuthUser, +) -> Result { 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); diff --git a/src/reader/mark_read.rs b/src/reader/mark_read.rs index aa8f557..f488be2 100644 --- a/src/reader/mark_read.rs +++ b/src/reader/mark_read.rs @@ -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, + auth_user: AuthUser, ) -> Result { let mut connection = establish_connection(); log::info!("Id: {}", path.id); - let mut feed_items: Vec = 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::(&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); + } } diff --git a/src/reader/mod.rs b/src/reader/mod.rs index 23a9349..4d27fcb 100755 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -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; diff --git a/src/reader/net.rs b/src/reader/net.rs new file mode 100644 index 0000000..ae96cf0 --- /dev/null +++ b/src/reader/net.rs @@ -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 { + 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 { + 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()); + } +} diff --git a/src/reader/scraper/content.rs b/src/reader/scraper/content.rs index defbcef..5d823a4 100644 --- a/src/reader/scraper/content.rs +++ b/src/reader/scraper/content.rs @@ -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 { - let response = reqwest::get(url).await?; - response.text().await +pub async fn do_throttled_request(url: &str) -> Result { + let response = safe_fetch(url).await?; + Ok(response.text().await?) } diff --git a/src/reader/sync.rs b/src/reader/sync.rs index 56b79c7..6cea42f 100644 --- a/src/reader/sync.rs +++ b/src/reader/sync.rs @@ -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 { if let Ok(result) = parse(date_str) { @@ -47,6 +49,20 @@ fn escape_html_attr(value: &str) -> String { .replace('>', ">") } +// Feed-supplied `` 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 in the item content at // all — they carry the article image as an RSS instead. Build an // 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("
"); } 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("
"); } } @@ -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, -) -> Result { - let mut connection: diesel::PgConnection = establish_connection(); - + auth_user: AuthUser, +) -> Result { 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::table .filter(user_id.eq(req_user_id)) .load::(&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#""#); + + 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#""#, + ); + + 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#"

text

"# + .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(); diff --git a/src/schema.rs b/src/schema.rs index 3e0e206..e962c3c 100755 --- a/src/schema.rs +++ b/src/schema.rs @@ -28,6 +28,7 @@ diesel::table! { email -> Varchar, password -> Varchar, unique_id -> Varchar, + token_version -> Int4, } } diff --git a/src/views/auth/login.rs b/src/views/auth/login.rs index 63c9aa2..8bdd1e0 100755 --- a/src/views/auth/login.rs +++ b/src/views/auth/login.rs @@ -33,7 +33,7 @@ pub async fn login(credentials: web::Json) -> Result { 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)) diff --git a/src/views/auth/logout.rs b/src/views/auth/logout.rs index 7ee2e40..f9586be 100755 --- a/src/views/auth/logout.rs +++ b/src/views/auth/logout.rs @@ -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 { + 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); } } diff --git a/src/views/auth/mod.rs b/src/views/auth/mod.rs index e5c644a..f4fee62 100755 --- a/src/views/auth/mod.rs +++ b/src/views/auth/mod.rs @@ -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")), diff --git a/src/views/users/create.rs b/src/views/users/create.rs index 766cd57..8f56781 100755 --- a/src/views/users/create.rs +++ b/src/views/users/create.rs @@ -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) -> Result import { ref, computed, onMounted, onUnmounted } from 'vue' import { RouterLink, useRouter, useRoute } from 'vue-router' -import { useFeeds } from '@/composables/useFeeds' +import { useFeeds, logout as logoutSession } from '@/composables/useFeeds' import Modal from './modal/AddUrl.vue' const router = useRouter() @@ -36,9 +36,8 @@ function closeMenu() { menuOpen.value = false } -function logout() { - localStorage.removeItem('user-token') - localStorage.removeItem('user-id') +async function logout() { + await logoutSession() closeMenu() router.push({ name: 'login' }) } diff --git a/vue/src/composables/useFeeds.js b/vue/src/composables/useFeeds.js index d3debfc..2801020 100644 --- a/vue/src/composables/useFeeds.js +++ b/vue/src/composables/useFeeds.js @@ -16,7 +16,7 @@ const navTitleVisible = ref(true) // whether AppNav's "RSS Reader (N)" title is let observer; // Declare observer outside the setup function let initialLoad = false -function authHeaders() { +export function authHeaders() { return { headers: { 'Content-Type': 'application/json', @@ -25,6 +25,20 @@ function authHeaders() { } } +// Tells the server to revoke the current token (bumps token_version, so any +// other outstanding tokens for this account are invalidated too) before +// clearing the local session. Best-effort: if the request fails (e.g. the +// token already expired) the local session is cleared regardless. +export async function logout() { + try { + await axios.post('/api/v1/auth/logout', null, authHeaders()) + } catch (error) { + console.error('Error logging out', error) + } + localStorage.removeItem('user-token') + localStorage.removeItem('user-id') +} + // Some feeds (e.g. Deutsche Welle) ship tags whose `src` and various // lazy-load attributes (`data-url`, `data-src`, `srcset`, ...) contain an // unresolved `${placeholderName}` template — or its URL-encoded `%7B...%7D` diff --git a/vue/src/main.js b/vue/src/main.js index b56b0f7..bab6815 100644 --- a/vue/src/main.js +++ b/vue/src/main.js @@ -1,8 +1,28 @@ import './assets/main.css' +import axios from 'axios' import { createApp } from 'vue' import App from './App.vue' import router from './router' + +// A 401 means the server has rejected the token (missing, expired, or +// revoked via logout/token_version bump elsewhere). Drop the stale session +// and send the user back to login rather than leaving them on a page where +// every request silently fails. +axios.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem('user-token') + localStorage.removeItem('user-id') + if (router.currentRoute.value.name !== 'login') { + router.push({ name: 'login' }) + } + } + return Promise.reject(error) + } +) + const app = createApp(App) app.use(router)