···11+# Known Issues
22+33+## Account migration from bsky.social
44+55+Migrating your account from bsky.social to this PDS works, but Bluesky's appview may not recognize your new signing key. This means you can post and your followers will see it, but some authenticated requests might fail with "jwt signature does not match jwt issuer".
66+77+We've been trying hard to verify that our side is correct (PLC updated, signing keys match, relays have the account) but something about how we're emitting events isn't triggering Bluesky's appview to refresh its identity data. Still investigating.
88+99+No workaround yet.
···11/*
22- * CONTENT WARNING
33- *
44- * This file contains explicit slurs and hateful language. We're sorry you have to see them.
55- *
66- * These words exist here for one reason: to ensure our moderation system correctly blocks them.
77- * We can't verify the filter catches the n-word without testing against the actual word.
88- * Euphemisms wouldn't prove the protection works.
99- *
1010- * If reading this file has caused you distress, please know:
1111- * - you are valued and welcome in this community
1212- * - these words do not reflect the views of this project or its contributors
1313- * - we maintain this code precisely because we believe everyone deserves an experience on the web that is free from this kinda language
22+ * CONTENT WARNING
33+ *
44+ * This file contains explicit slurs and hateful language. We're sorry you have to see them.
55+ *
66+ * These words exist here for one reason: to ensure our moderation system correctly blocks them.
77+ * We can't verify the filter catches the n-word without testing against the actual word.
88+ * Euphemisms wouldn't prove the protection works.
99+ *
1010+ * If reading this file has caused you distress, please know:
1111+ * - you are valued and welcome in this community
1212+ * - these words do not reflect the views of this project or its contributors
1313+ * - we maintain this code precisely because we believe everyone deserves an experience on the web that is free from this kinda language
1414*/
15151616use regex::Regex;
···70707171fn has_explicit_slur_with_extra_words(text: &str, extra_words: &[String]) -> bool {
7272 let text_lower = text.to_lowercase();
7373- let normalized = text_lower.replace('.', "").replace('-', "").replace('_', "");
7373+ let normalized = text_lower.replace(['.', '-', '_'], "");
7474 let stripped = strip_trailing_digits(&text_lower);
7575 let normalized_stripped = strip_trailing_digits(&normalized);
7676···104104#[cfg(test)]
105105mod tests {
106106 use super::*;
107107+ use base64::Engine;
108108+109109+ fn d(b64: &str) -> String {
110110+ let bytes = base64::engine::general_purpose::STANDARD.decode(b64).unwrap();
111111+ String::from_utf8(bytes).unwrap()
112112+ }
107113108114 #[test]
109115 fn test_chink_pattern() {
110110- assert!(has_explicit_slur("chink"));
111111- assert!(has_explicit_slur("chinks"));
112112- assert!(has_explicit_slur("CHINK"));
113113- assert!(has_explicit_slur("Chinks"));
116116+ assert!(has_explicit_slur(&d("Y2hpbms=")));
117117+ assert!(has_explicit_slur(&d("Y2hpbmtz")));
118118+ assert!(has_explicit_slur(&d("Q0hJTks=")));
119119+ assert!(has_explicit_slur(&d("Q2hpbmtz")));
114120 }
115121116122 #[test]
117123 fn test_coon_pattern() {
118118- assert!(has_explicit_slur("coon"));
119119- assert!(has_explicit_slur("coons"));
120120- assert!(has_explicit_slur("COON"));
124124+ assert!(has_explicit_slur(&d("Y29vbg==")));
125125+ assert!(has_explicit_slur(&d("Y29vbnM=")));
126126+ assert!(has_explicit_slur(&d("Q09PTg==")));
121127 }
122128123129 #[test]
124130 fn test_fag_pattern() {
125125- assert!(has_explicit_slur("fag"));
126126- assert!(has_explicit_slur("fags"));
127127- assert!(has_explicit_slur("faggot"));
128128- assert!(has_explicit_slur("faggots"));
129129- assert!(has_explicit_slur("faggotry"));
131131+ assert!(has_explicit_slur(&d("ZmFn")));
132132+ assert!(has_explicit_slur(&d("ZmFncw==")));
133133+ assert!(has_explicit_slur(&d("ZmFnZ290")));
134134+ assert!(has_explicit_slur(&d("ZmFnZ290cw==")));
135135+ assert!(has_explicit_slur(&d("ZmFnZ290cnk=")));
130136 }
131137132138 #[test]
133139 fn test_kike_pattern() {
134134- assert!(has_explicit_slur("kike"));
135135- assert!(has_explicit_slur("kikes"));
136136- assert!(has_explicit_slur("KIKE"));
137137- assert!(has_explicit_slur("kikery"));
140140+ assert!(has_explicit_slur(&d("a2lrZQ==")));
141141+ assert!(has_explicit_slur(&d("a2lrZXM=")));
142142+ assert!(has_explicit_slur(&d("S0lLRQ==")));
143143+ assert!(has_explicit_slur(&d("a2lrZXJ5")));
138144 }
139145140146 #[test]
141147 fn test_nigger_pattern() {
142142- assert!(has_explicit_slur("nigger"));
143143- assert!(has_explicit_slur("niggers"));
144144- assert!(has_explicit_slur("NIGGER"));
145145- assert!(has_explicit_slur("nigga"));
146146- assert!(has_explicit_slur("niggas"));
148148+ assert!(has_explicit_slur(&d("bmlnZ2Vy")));
149149+ assert!(has_explicit_slur(&d("bmlnZ2Vycw==")));
150150+ assert!(has_explicit_slur(&d("TklHR0VS")));
151151+ assert!(has_explicit_slur(&d("bmlnZ2E=")));
152152+ assert!(has_explicit_slur(&d("bmlnZ2Fz")));
147153 }
148154149155 #[test]
150156 fn test_tranny_pattern() {
151151- assert!(has_explicit_slur("tranny"));
152152- assert!(has_explicit_slur("trannies"));
153153- assert!(has_explicit_slur("TRANNY"));
157157+ assert!(has_explicit_slur(&d("dHJhbm55")));
158158+ assert!(has_explicit_slur(&d("dHJhbm5pZXM=")));
159159+ assert!(has_explicit_slur(&d("VFJBTk5Z")));
154160 }
155161156162 #[test]
157163 fn test_normalization_bypass() {
158158- assert!(has_explicit_slur("n.i.g.g.e.r"));
159159- assert!(has_explicit_slur("n-i-g-g-e-r"));
160160- assert!(has_explicit_slur("n_i_g_g_e_r"));
161161- assert!(has_explicit_slur("f.a.g"));
162162- assert!(has_explicit_slur("f-a-g"));
163163- assert!(has_explicit_slur("c.h.i.n.k"));
164164- assert!(has_explicit_slur("k_i_k_e"));
164164+ assert!(has_explicit_slur(&d("bi5pLmcuZy5lLnI=")));
165165+ assert!(has_explicit_slur(&d("bi1pLWctZy1lLXI=")));
166166+ assert!(has_explicit_slur(&d("bl9pX2dfZ19lX3I=")));
167167+ assert!(has_explicit_slur(&d("Zi5hLmc=")));
168168+ assert!(has_explicit_slur(&d("Zi1hLWc=")));
169169+ assert!(has_explicit_slur(&d("Yy5oLmkubi5r")));
170170+ assert!(has_explicit_slur(&d("a19pX2tfZQ==")));
165171 }
166172167173 #[test]
168174 fn test_trailing_digits_bypass() {
169169- assert!(has_explicit_slur("faggot123"));
170170- assert!(has_explicit_slur("nigger69"));
171171- assert!(has_explicit_slur("chink420"));
172172- assert!(has_explicit_slur("fag1"));
173173- assert!(has_explicit_slur("kike2024"));
174174- assert!(has_explicit_slur("n_i_g_g_e_r123"));
175175+ assert!(has_explicit_slur(&d("ZmFnZ290MTIz")));
176176+ assert!(has_explicit_slur(&d("bmlnZ2VyNjk=")));
177177+ assert!(has_explicit_slur(&d("Y2hpbms0MjA=")));
178178+ assert!(has_explicit_slur(&d("ZmFnMQ==")));
179179+ assert!(has_explicit_slur(&d("a2lrZTIwMjQ=")));
180180+ assert!(has_explicit_slur(&d("bl9pX2dfZ19lX3IxMjM=")));
175181 }
176182177183 #[test]
178184 fn test_embedded_in_sentence() {
179179- assert!(has_explicit_slur("you are a faggot"));
180180- assert!(has_explicit_slur("stupid nigger"));
181181- assert!(has_explicit_slur("go away chink"));
185185+ assert!(has_explicit_slur(&d("eW91IGFyZSBhIGZhZ2dvdA==")));
186186+ assert!(has_explicit_slur(&d("c3R1cGlkIG5pZ2dlcg==")));
187187+ assert!(has_explicit_slur(&d("Z28gYXdheSBjaGluaw==")));
182188 }
183189184190 #[test]
···210216211217 #[test]
212218 fn test_case_insensitive() {
213213- assert!(has_explicit_slur("NIGGER"));
214214- assert!(has_explicit_slur("Nigger"));
215215- assert!(has_explicit_slur("NiGgEr"));
216216- assert!(has_explicit_slur("FAGGOT"));
217217- assert!(has_explicit_slur("Faggot"));
219219+ assert!(has_explicit_slur(&d("TklHR0VS")));
220220+ assert!(has_explicit_slur(&d("TmlnZ2Vy")));
221221+ assert!(has_explicit_slur(&d("TmlHZ0Vy")));
222222+ assert!(has_explicit_slur(&d("RkFHR09U")));
223223+ assert!(has_explicit_slur(&d("RmFnZ290")));
218224 }
219225220226 #[test]
221227 fn test_leetspeak_bypass() {
222222- assert!(has_explicit_slur("f4ggot"));
223223- assert!(has_explicit_slur("f4gg0t"));
224224- assert!(has_explicit_slur("n1gger"));
225225- assert!(has_explicit_slur("n1gg3r"));
226226- assert!(has_explicit_slur("k1ke"));
227227- assert!(has_explicit_slur("ch1nk"));
228228- assert!(has_explicit_slur("tr4nny"));
228228+ assert!(has_explicit_slur(&d("ZjRnZ290")));
229229+ assert!(has_explicit_slur(&d("ZjRnZzB0")));
230230+ assert!(has_explicit_slur(&d("bjFnZ2Vy")));
231231+ assert!(has_explicit_slur(&d("bjFnZzNy")));
232232+ assert!(has_explicit_slur(&d("azFrZQ==")));
233233+ assert!(has_explicit_slur(&d("Y2gxbms=")));
234234+ assert!(has_explicit_slur(&d("dHI0bm55")));
229235 }
230236231237 #[test]
···253259 assert!(has_explicit_slur_with_extra_words("b4dw0rd", &extra));
254260 assert!(has_explicit_slur_with_extra_words("b4dw0rd789", &extra));
255261 assert!(has_explicit_slur_with_extra_words("b.4.d.w.0.r.d", &extra));
256256- assert!(has_explicit_slur_with_extra_words("this contains badword here", &extra));
262262+ assert!(has_explicit_slur_with_extra_words(
263263+ "this contains badword here",
264264+ &extra
265265+ ));
257266 assert!(has_explicit_slur_with_extra_words("0ff3n$1v3", &extra));
258267259268 assert!(!has_explicit_slur_with_extra_words("goodword", &extra));
+12-9
src/oauth/endpoints/delegation.rs
···8888 }
8989 };
90909191- if let Err(_) = db::set_request_did(&state.db, &form.request_uri, &delegated_did).await {
9191+ if db::set_request_did(&state.db, &form.request_uri, &delegated_did)
9292+ .await
9393+ .is_err()
9494+ {
9295 tracing::warn!("Failed to set delegated DID on authorization request");
9396 }
9497···168171 .into_response();
169172 }
170173171171- let password_valid = match &controller.password_hash {
172172- Some(hash) => match bcrypt::verify(&form.password, hash) {
173173- Ok(valid) => valid,
174174- Err(_) => false,
175175- },
176176- None => false,
177177- };
174174+ let password_valid = controller
175175+ .password_hash
176176+ .as_ref()
177177+ .map(|hash| bcrypt::verify(&form.password, hash).unwrap_or_default())
178178+ .unwrap_or_default();
178179179180 if !password_valid {
180181 return Json(DelegationAuthResponse {
···186187 .into_response();
187188 }
188189189189- if let Err(_) = db::set_controller_did(&state.db, &form.request_uri, &form.controller_did).await
190190+ if db::set_controller_did(&state.db, &form.request_uri, &form.controller_did)
191191+ .await
192192+ .is_err()
190193 {
191194 return Json(DelegationAuthResponse {
192195 success: false,
+27-26
src/sync/import.rs
···189189 if let Some(Ipld::List(entries)) = obj.get("e") {
190190 for entry in entries {
191191 if let Ipld::Map(entry_obj) = entry {
192192- let prefix_len = entry_obj.get("p").and_then(|p| {
193193- if let Ipld::Integer(n) = p {
194194- Some(*n as usize)
195195- } else {
196196- None
197197- }
198198- }).unwrap_or(0);
192192+ let prefix_len = entry_obj
193193+ .get("p")
194194+ .and_then(|p| {
195195+ if let Ipld::Integer(n) = p {
196196+ Some(*n as usize)
197197+ } else {
198198+ None
199199+ }
200200+ })
201201+ .unwrap_or(0);
199202200203 let key_suffix = entry_obj.get("k").and_then(|k| {
201204 if let Ipld::Bytes(b) = k {
···222225 }
223226 });
224227225225- if let Some(record_cid) = record_cid {
226226- if let Ok(full_key) = String::from_utf8(current_key.clone()) {
227227- if let Some(record_block) = blocks.get(&record_cid)
228228- && let Ok(record_value) =
229229- serde_ipld_dagcbor::from_slice::<Ipld>(record_block)
230230- {
231231- let blob_refs = find_blob_refs_ipld(&record_value, 0);
232232- let parts: Vec<&str> = full_key.split('/').collect();
233233- if parts.len() >= 2 {
234234- let collection = parts[..parts.len() - 1].join("/");
235235- let rkey = parts[parts.len() - 1].to_string();
236236- records.push(ImportedRecord {
237237- collection,
238238- rkey,
239239- cid: record_cid,
240240- blob_refs,
241241- });
242242- }
243243- }
228228+ if let Some(record_cid) = record_cid
229229+ && let Ok(full_key) = String::from_utf8(current_key.clone())
230230+ && let Some(record_block) = blocks.get(&record_cid)
231231+ && let Ok(record_value) =
232232+ serde_ipld_dagcbor::from_slice::<Ipld>(record_block)
233233+ {
234234+ let blob_refs = find_blob_refs_ipld(&record_value, 0);
235235+ let parts: Vec<&str> = full_key.split('/').collect();
236236+ if parts.len() >= 2 {
237237+ let collection = parts[..parts.len() - 1].join("/");
238238+ let rkey = parts[parts.len() - 1].to_string();
239239+ records.push(ImportedRecord {
240240+ collection,
241241+ rkey,
242242+ cid: record_cid,
243243+ blob_refs,
244244+ });
244245 }
245246 }
246247 }
+13-14
src/validation/mod.rs
···161161 .get("$type")
162162 .and_then(|v| v.as_str())
163163 .is_some_and(|t| t == "app.bsky.richtext.facet#tag");
164164- if is_tag {
165165- if let Some(tag) = feature.get("tag").and_then(|v| v.as_str()) {
166166- if crate::moderation::has_explicit_slur(tag) {
167167- return Err(ValidationError::BannedContent {
168168- path: format!("facets/{}/features/{}/tag", i, j),
169169- });
170170- }
171171- }
164164+ if is_tag
165165+ && let Some(tag) = feature.get("tag").and_then(|v| v.as_str())
166166+ && crate::moderation::has_explicit_slur(tag)
167167+ {
168168+ return Err(ValidationError::BannedContent {
169169+ path: format!("facets/{}/features/{}/tag", i, j),
170170+ });
172171 }
173172 }
174173 }
···332331 if !obj.contains_key("createdAt") {
333332 return Err(ValidationError::MissingField("createdAt".to_string()));
334333 }
335335- if let Some(rkey) = rkey {
336336- if crate::moderation::has_explicit_slur(rkey) {
337337- return Err(ValidationError::BannedContent {
338338- path: "rkey".to_string(),
339339- });
340340- }
334334+ if let Some(rkey) = rkey
335335+ && crate::moderation::has_explicit_slur(rkey)
336336+ {
337337+ return Err(ValidationError::BannedContent {
338338+ path: "rkey".to_string(),
339339+ });
341340 }
342341 if let Some(display_name) = obj.get("displayName").and_then(|v| v.as_str()) {
343342 if display_name.is_empty() || display_name.len() > 240 {
+34-22
tests/banned_words.rs
···11/*
22- * CONTENT WARNING
33- *
44- * This file contains explicit slurs and hateful language. We're sorry you have to see them.
55- *
66- * These words exist here for one reason: to ensure our moderation system correctly blocks them.
77- * We can't verify the filter catches the n-word without testing against the actual word.
88- * Euphemisms wouldn't prove the protection works.
99- *
1010- * If reading this file has caused you distress, please know:
1111- * - you are valued and welcome in this community
1212- * - these words do not reflect the views of this project or its contributors
1313- * - we maintain this code precisely because we believe everyone deserves an experience on the web that is free from this kinda language
1414-*/
22+ * CONTENT WARNING
33+ *
44+ * This file contains explicit slurs and hateful language. We're sorry you have to see them.
55+ *
66+ * These words exist here for one reason: to ensure our moderation system correctly blocks them.
77+ * We can't verify the filter catches the n-word without testing against the actual word.
88+ * Euphemisms wouldn't prove the protection works.
99+ *
1010+ * If reading this file has caused you distress, please know:
1111+ * - you are valued and welcome in this community
1212+ * - these words do not reflect the views of this project or its contributors
1313+ * - we maintain this code precisely because we believe everyone deserves an experience on the web that is free from this kinda language
1414+ */
15151616mod common;
1717mod helpers;
···2020use reqwest::StatusCode;
2121use serde_json::json;
22222323+fn decode(b64: &str) -> String {
2424+ use base64::Engine;
2525+ let bytes = base64::engine::general_purpose::STANDARD
2626+ .decode(b64)
2727+ .expect("invalid base64 in test");
2828+ String::from_utf8(bytes).expect("invalid utf8 in test")
2929+}
3030+2331#[tokio::test]
2432async fn test_handle_with_slur_rejected() {
2533 let client = client();
2634 let timestamp = chrono::Utc::now().timestamp_millis();
2727- let offensive_handle = format!("nigger{}", timestamp);
3535+ let slur = decode("bmlnZ2Vy");
3636+ let offensive_handle = format!("{}{}", slur, timestamp);
28372938 let create_payload = json!({
3039 "handle": offensive_handle,
···4554 assert_eq!(res.status(), StatusCode::BAD_REQUEST);
4655 let body: serde_json::Value = res.json().await.unwrap();
4756 assert_eq!(body["error"], "InvalidHandle");
4848- assert!(body["message"]
4949- .as_str()
5050- .unwrap_or("")
5151- .contains("Inappropriate language"));
5757+ assert!(
5858+ body["message"]
5959+ .as_str()
6060+ .unwrap_or("")
6161+ .contains("Inappropriate language")
6262+ );
5263}
53645465#[tokio::test]
5566async fn test_handle_with_normalized_slur_rejected() {
5667 let client = client();
5768 let timestamp = chrono::Utc::now().timestamp_millis();
5858- let offensive_handle = format!("n-i-g-g-e-r{}", timestamp);
6969+ let slur = decode("bi1pLWctZy1lLXI=");
7070+ let offensive_handle = format!("{}{}", slur, timestamp);
59716072 let create_payload = json!({
6173 "handle": offensive_handle,
···8496 let (_, jwt) = setup_new_user("handleupdate").await;
85978698 let update_payload = json!({
8787- "handle": "faggots"
9999+ "handle": decode("ZmFnZ290cw==")
88100 });
8910190102 let res = client
···114126 "rkey": "self",
115127 "record": {
116128 "$type": "app.bsky.actor.profile",
117117- "displayName": "I am a kike"
129129+ "displayName": decode("SSBhbSBhIGtpa2U=")
118130 }
119131 });
120132···146158 "record": {
147159 "$type": "app.bsky.actor.profile",
148160 "displayName": "Normal Name",
149149- "description": "I hate all chinks"
161161+ "description": decode("SSBoYXRlIGFsbCBjaGlua3M=")
150162 }
151163 });
152164
+10-24
tests/firehose_validation.rs
···364364 let client = client();
365365 let (token, did) = create_account_and_login(&client).await;
366366367367- let url = format!(
368368- "ws://127.0.0.1:{}/xrpc/com.atproto.sync.subscribeRepos",
369369- app_port()
370370- );
371371- let (mut ws_stream, _) = connect_async(&url).await.expect("Failed to connect");
372372-373367 let profile_payload = json!({
374368 "repo": did,
375369 "collection": "app.bsky.actor.profile",
···393387 let first_profile: Value = res.json().await.unwrap();
394388 let first_cid = first_profile["cid"].as_str().unwrap();
395389396396- let timeout = tokio::time::timeout(std::time::Duration::from_secs(5), async {
397397- loop {
398398- let msg = ws_stream.next().await.unwrap().unwrap();
399399- let raw_bytes = match msg {
400400- tungstenite::Message::Binary(bin) => bin,
401401- _ => continue,
402402- };
403403- if let Ok((_, f)) = parse_frame(&raw_bytes) {
404404- if f.repo == did {
405405- break;
406406- }
407407- }
408408- }
409409- })
410410- .await;
411411- assert!(timeout.is_ok(), "Timed out waiting for first commit");
390390+ let url = format!(
391391+ "ws://127.0.0.1:{}/xrpc/com.atproto.sync.subscribeRepos",
392392+ app_port()
393393+ );
394394+ let (mut ws_stream, _) = connect_async(&url).await.expect("Failed to connect");
412395413396 let update_payload = json!({
414397 "repo": did,
···432415 assert_eq!(res.status(), StatusCode::OK);
433416434417 let mut frame_opt: Option<CommitFrame> = None;
435435- let timeout = tokio::time::timeout(std::time::Duration::from_secs(5), async {
418418+ let timeout = tokio::time::timeout(std::time::Duration::from_secs(15), async {
436419 loop {
437437- let msg = ws_stream.next().await.unwrap().unwrap();
420420+ let msg = match ws_stream.next().await {
421421+ Some(Ok(m)) => m,
422422+ _ => continue,
423423+ };
438424 let raw_bytes = match msg {
439425 tungstenite::Message::Binary(bin) => bin,
440426 _ => continue,