Improve security

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