Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion e2e/dune
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
(library
(name e2e)
(modules config nostr_signer http_client test_upload test_delete test_cors test_get test_head_upload test_mirror test_list)
(modules config nostr_signer http_client test_upload test_delete test_cors test_get test_head_upload test_mirror test_list test_report)
(libraries
blossoML.core
piaf
Expand Down
3 changes: 3 additions & 0 deletions e2e/main.ml
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,7 @@ let () =
Eio.traceln "\nList Tests (BUD-12):";
List.iter (run_test ~sw ~env) E2e.Test_list.tests;

Eio.traceln "\nReport Tests (BUD-09):";
List.iter (run_test ~sw ~env) E2e.Test_report.tests;

Eio.traceln "\nDone."
23 changes: 23 additions & 0 deletions e2e/nostr_signer.ml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,29 @@ let create_list_auth ~keypair ~created_at ~expiration =
| Ok (sig_, _) ->
{ Nostr_event.id; pubkey = keypair.pubkey; created_at; kind; tags; content; sig_ }

(** Create a BUD-09 / NIP-56 report event (kind 1984).
[entries] is a list of (sha256, report_type) pairs. *)
let create_report ~keypair ~created_at ~entries ?(content = "") ?e_tag ?p_tag () =
let created_at = Int64.of_float created_at in
let x_tags = List.map (fun (sha, rt) -> ["x"; sha; rt]) entries in
let extra_tags =
(match e_tag with Some v -> [["e"; v]] | None -> [])
@ (match p_tag with Some v -> [["p"; v]] | None -> [])
in
let tags = x_tags @ extra_tags in
let kind = 1984 in
let id = Nostr_event.compute_id
~pubkey:keypair.pubkey
~created_at
~kind
~tags
~content
in
match Bip340.sign ~secret_key:keypair.secret_key ~msg:id with
| Error _ -> failwith "Failed to sign report event"
| Ok (sig_, _) ->
{ Nostr_event.id; pubkey = keypair.pubkey; created_at; kind; tags; content; sig_ }

(** Convert a Nostr event to JSON string. *)
let event_to_json event =
let open Nostr_event in
Expand Down
8 changes: 4 additions & 4 deletions e2e/test_get.ml
Original file line number Diff line number Diff line change
Expand Up @@ -254,12 +254,12 @@ let test_get_binary_blob ~sw ~env =
let test_get_invalid_paths ~sw ~env =
let base_url = Config.base_url in

(* Note: GET / is no longer invalid — it returns 200 with Terms of Service (BUD-09).
See test_report.ml for that case. *)
let invalid_paths = [
"/"; (* Root path *)
"/upload"; (* Upload endpoint *)
"/../etc/passwd"; (* Path traversal attempt *)
"/a/b/c"; (* Multiple segments *)
""; (* Empty path *)
] in

List.iter (fun path ->
Expand Down Expand Up @@ -638,9 +638,9 @@ let test_head_get_consistent_headers ~sw ~env =
let test_head_invalid_paths ~sw ~env =
let base_url = Config.base_url in

(* Note: /upload is now a valid endpoint (BUD-06), so it's not included here *)
(* Note: /upload is now a valid endpoint (BUD-06), so it's not included here.
GET / is now Terms of Service (BUD-09); HEAD / would still 404. *)
let invalid_paths = [
"/";
"/../etc/passwd";
"/a/b/c";
] in
Expand Down
132 changes: 132 additions & 0 deletions e2e/test_report.ml
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
(** E2E tests for BUD-09 PUT /report and GET / (Terms of Service). *)

let sha256_hex content =
let hash = Digestif.SHA256.digest_string content in
Digestif.SHA256.to_hex hash

(** Test: GET / returns 200 and text/plain Terms of Service *)
let test_get_root_terms_of_service ~sw ~env =
let base_url = Config.base_url in
let result = Http_client.get ~sw ~env ~url:base_url () in
match result with
| Error e -> failwith ("GET / failed: " ^ e)
| Ok response ->
if response.status <> 200 then
failwith (Printf.sprintf "Expected 200, got %d" response.status);
let ct = List.find_opt
(fun (k, _) -> String.lowercase_ascii k = "content-type") response.headers
in
(match ct with
| None -> failwith "Missing Content-Type"
| Some (_, v) ->
if not (String.starts_with ~prefix:"text/plain" v) then
failwith (Printf.sprintf "Expected text/plain, got %s" v));
if String.length response.body = 0 then
failwith "Empty ToS body";
(* must mention the report endpoint *)
let contains s sub =
try
let len_sub = String.length sub in
let len_s = String.length s in
let rec loop i =
if i + len_sub > len_s then false
else if String.sub s i len_sub = sub then true
else loop (i + 1)
in loop 0
with _ -> false
in
if not (contains response.body "report") then
failwith "ToS should mention reporting"

(** Test: PUT /report with a valid signed report returns 200 *)
let test_put_report_accepted ~sw ~env =
let base_url = Config.base_url in
let clock = Eio.Stdenv.clock env in
let now = Eio.Time.now clock in
let keypair = Nostr_signer.generate_keypair () in
let some_sha = sha256_hex "irrelevant content for report-only test" in
let event = Nostr_signer.create_report
~keypair ~created_at:now
~entries:[(some_sha, "spam")]
~content:"this is spam"
()
in
let body = Nostr_signer.event_to_json event in
let result = Http_client.put ~sw ~env ~url:(base_url ^ "/report")
~headers:[("Content-Type", "application/json")] ~body ()
in
match result with
| Error e -> failwith ("PUT /report failed: " ^ e)
| Ok response ->
if response.status <> 200 then
failwith (Printf.sprintf "Expected 200, got %d (body: %s)"
response.status response.body)

(** Test: PUT /report with multiple x tags is accepted *)
let test_put_report_multiple_x_tags ~sw ~env =
let base_url = Config.base_url in
let clock = Eio.Stdenv.clock env in
let now = Eio.Time.now clock in
let keypair = Nostr_signer.generate_keypair () in
let sha1 = sha256_hex "blob 1" in
let sha2 = sha256_hex "blob 2" in
let event = Nostr_signer.create_report
~keypair ~created_at:now
~entries:[(sha1, "malware"); (sha2, "illegal")]
~content:""
()
in
let body = Nostr_signer.event_to_json event in
let result = Http_client.put ~sw ~env ~url:(base_url ^ "/report")
~headers:[("Content-Type", "application/json")] ~body ()
in
match result with
| Error e -> failwith ("PUT /report failed: " ^ e)
| Ok response ->
if response.status <> 200 then
failwith (Printf.sprintf "Expected 200, got %d (body: %s)"
response.status response.body)

(** Test: PUT /report rejects an invalid (non-NIP-56) body *)
let test_put_report_rejects_invalid_body ~sw ~env =
let base_url = Config.base_url in
let result = Http_client.put ~sw ~env ~url:(base_url ^ "/report")
~headers:[("Content-Type", "application/json")]
~body:"{\"not\":\"a report\"}" ()
in
match result with
| Error e -> failwith ("Request failed: " ^ e)
| Ok response ->
if response.status <> 400 then
failwith (Printf.sprintf "Expected 400 for invalid body, got %d" response.status)

(** Test: PUT /report rejects an unknown report type *)
let test_put_report_rejects_unknown_type ~sw ~env =
let base_url = Config.base_url in
let clock = Eio.Stdenv.clock env in
let now = Eio.Time.now clock in
let keypair = Nostr_signer.generate_keypair () in
let some_sha = sha256_hex "x" in
let event = Nostr_signer.create_report
~keypair ~created_at:now
~entries:[(some_sha, "vandalism")]
~content:""
()
in
let body = Nostr_signer.event_to_json event in
let result = Http_client.put ~sw ~env ~url:(base_url ^ "/report")
~headers:[("Content-Type", "application/json")] ~body ()
in
match result with
| Error e -> failwith ("Request failed: " ^ e)
| Ok response ->
if response.status <> 400 then
failwith (Printf.sprintf "Expected 400 for invalid report type, got %d" response.status)

let tests = [
("GET / returns ToS", test_get_root_terms_of_service);
("PUT /report accepted", test_put_report_accepted);
("PUT /report multiple x tags", test_put_report_multiple_x_tags);
("PUT /report rejects invalid body", test_put_report_rejects_invalid_body);
("PUT /report rejects unknown type", test_put_report_rejects_unknown_type);
]
1 change: 1 addition & 0 deletions lib/core/domain.ml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type error =
| Mirror_invalid_url of string (* invalid mirror URL -> 400 *)
| Mirror_fetch_error of string (* remote fetch failed -> 502 *)
| Mirror_ssrf_blocked of string (* SSRF protection blocked URL -> 400, detail for logging *)
| Report_error of string (* invalid NIP-56 report event -> 400 *)

(** Mirror request body *)
type mirror_request = {
Expand Down
142 changes: 142 additions & 0 deletions lib/core/report.ml
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
(** NIP-56 blob report event validation (BUD-09).

A report is a kind:1984 Nostr event with one or more `x` tags, each
containing the sha256 of a reported blob and a report type. *)

type report_type =
| Nudity
| Malware
| Profanity
| Illegal
| Spam
| Impersonation
| Other

let report_type_of_string = function
| "nudity" -> Some Nudity
| "malware" -> Some Malware
| "profanity" -> Some Profanity
| "illegal" -> Some Illegal
| "spam" -> Some Spam
| "impersonation" -> Some Impersonation
| "other" -> Some Other
| _ -> None

let report_type_to_string = function
| Nudity -> "nudity"
| Malware -> "malware"
| Profanity -> "profanity"
| Illegal -> "illegal"
| Spam -> "spam"
| Impersonation -> "impersonation"
| Other -> "other"

type entry = {
sha256 : string;
report_type : report_type;
}

type t = {
event_id : string;
reporter_pubkey : string;
created_at : int64;
entries : entry list;
content : string;
e_tag : string option;
p_tag : string option;
raw_event_json : string;
}

(** Parse a JSON string as a Nostr event. *)
let parse_event_json (body : string) : (Nostr_event.t, Domain.error) result =
try
let json = Yojson.Safe.from_string body in
let open Yojson.Safe.Util in
let event : Nostr_event.t = {
id = json |> member "id" |> to_string;
pubkey = json |> member "pubkey" |> to_string;
created_at = json |> member "created_at" |> to_int |> Int64.of_int;
kind = json |> member "kind" |> to_int;
tags = json |> member "tags" |> to_list |> List.map (fun tag -> tag |> to_list |> List.map to_string);
content = json |> member "content" |> to_string;
sig_ = json |> member "sig" |> to_string;
} in
Ok event
with
| Yojson.Json_error msg ->
Error (Domain.Report_error ("JSON parse error: " ^ msg))
| Yojson.Safe.Util.Type_error (msg, _) ->
Error (Domain.Report_error ("JSON type error: " ^ msg))

(** Extract all `x` tag entries (sha256, type). Each x tag MUST be
["x", "<sha256>", "<report_type>"]. *)
let extract_entries (event : Nostr_event.t) : (entry list, Domain.error) result =
let rec collect acc = function
| [] -> Ok (List.rev acc)
| tag :: rest ->
(match tag with
| "x" :: sha :: type_str :: _ ->
if not (Integrity.validate_hash sha) then
Error (Domain.Report_error ("Invalid sha256 in x tag: " ^ sha))
else
(match report_type_of_string type_str with
| None ->
Error (Domain.Report_error
(Printf.sprintf "Invalid report type '%s' (expected nudity/malware/profanity/illegal/spam/impersonation/other)" type_str))
| Some rt ->
collect ({ sha256 = sha; report_type = rt } :: acc) rest)
| "x" :: _ ->
Error (Domain.Report_error "x tag must contain sha256 and report type")
| _ -> collect acc rest)
in
match collect [] event.tags with
| Error e -> Error e
| Ok [] -> Error (Domain.Report_error "Report event must contain at least one x tag")
| Ok entries -> Ok entries

(** Validate the Nostr event's id and signature. *)
let verify_event (event : Nostr_event.t) : (unit, Domain.error) result =
if not (Nostr_event.verify_id event) then
Error (Domain.Report_error "Event ID does not match computed hash")
else
match Nostr_event.verify_signature event with
| Ok () -> Ok ()
| Error Nostr_event.Invalid_id_format ->
Error (Domain.Report_error "Invalid event ID format")
| Error Nostr_event.Invalid_pubkey_format ->
Error (Domain.Report_error "Invalid pubkey format")
| Error Nostr_event.Invalid_signature_format ->
Error (Domain.Report_error "Invalid signature format")
| Error Nostr_event.Signature_mismatch ->
Error (Domain.Report_error "Invalid signature")

(** Validate a NIP-56 report body for BUD-09 PUT /report.
[current_time] is accepted for future use (e.g., rejecting events
created far in the future). Currently we do not require an
`expiration` tag, as NIP-56 does not mandate one. *)
let validate ~current_time (body : string) : (t, Domain.error) result =
let _ = current_time in
match parse_event_json body with
| Error e -> Error e
| Ok event ->
if event.kind <> 1984 then
Error (Domain.Report_error "Invalid event kind, must be 1984")
else
match extract_entries event with
| Error e -> Error e
| Ok entries ->
match verify_event event with
| Error e -> Error e
| Ok () ->
let e_tag = Nostr_event.find_tag event "e" in
let p_tag = Nostr_event.find_tag event "p" in
Ok {
event_id = event.id;
reporter_pubkey = event.pubkey;
created_at = event.created_at;
entries;
content = event.content;
e_tag;
p_tag;
raw_event_json = body;
}
Loading
Loading