Accept a report, so blocking is not the only thing a person can do
There was no report mechanism anywhere — not in either app, not here. `Moderation.swift` held blocks and notifications and nothing else, which is why "report or block from a story" could not be built as one thing. 🔴 A report is the one place a user deliberately breaks their own confidentiality, and the row reflects that. The relay stores ciphertext it cannot open, so it cannot examine a reported story on its own account; a report carrying only an accusation would give an operator nothing but the assertion, which makes moderation either credulous or useless. So the reporter's own decrypted copy travels with it, and §6.25 requires the client to say so in those words before sending. ⚠️ There is deliberately NO route that reads reports back. An endpoint returning them would be a way to ask the relay what somebody reported, which is a question nobody but the operator gets to ask. The test asserts that against the router rather than by making a request, so adding one has to break it deliberately. ⚠️ An empty copy is accepted rather than refused. Refusing would mean the only way to report something is to hand over a copy of it, which is a worse bargain than the one §6.25 makes; the report is weaker, not invalid. `content` is `text`, not a varchar: a caption plus a note can exceed a driver's default and a report truncated by the schema is evidence quietly thrown away. It is bounded in the route instead, where the limit is visible. Three of these tests first passed for the wrong reason — the signing path was `/moderation/report` where the router mounts `/e2e/moderation/report`, so `authenticate` threw `unauthorized`, which is a `WireError` too and satisfied `#expect(throws: WireError.self)`. They assert the error code now. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
58e2d980c6
commit
9bab0874f9
@@ -926,6 +926,16 @@ func register(routes r: Routes, on app: Application) throws {
|
||||
e2e.get("turn") { req in
|
||||
try await r.turnCredentials(req, now: unixNow())
|
||||
}
|
||||
|
||||
// §6.25 — file a report.
|
||||
//
|
||||
// ⚠️ There is deliberately no route that READS reports. They are read
|
||||
// out of band by whoever operates the server; an endpoint returning
|
||||
// them would be a way to ask the relay what somebody reported, which
|
||||
// is a question nobody but the operator gets to ask.
|
||||
e2e.post("moderation", "report") { req in
|
||||
try await r.submitReport(req, now: unixNow())
|
||||
}
|
||||
}
|
||||
|
||||
mountE2E(app.grouped("e2e"))
|
||||
|
||||
@@ -608,6 +608,10 @@ enum RelayMigrations {
|
||||
AddAccountIdentity(), AddLogSubject(), CreateHomeRecord(), AddEnvelopeID(),
|
||||
AddDeviceLastSeen(), AddVoipToken(), AcceptExistingContacts(),
|
||||
BackfillKeyLog(), CreateDeliveryToken(), DropGroupNames(),
|
||||
SealGroupRosters(), CreateDeviceWipeOrder()]
|
||||
SealGroupRosters(), CreateDeviceWipeOrder(),
|
||||
// ⚠️ Appended, never inserted. Fluent runs these in registration
|
||||
// order and records what it has run; putting a new one in the middle
|
||||
// changes the order for a database that has already migrated.
|
||||
CreateContentReport()]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import Fluent
|
||||
import Foundation
|
||||
import SQLKit
|
||||
import Vapor
|
||||
|
||||
/// A report the operator can actually act on (spec §6.25).
|
||||
///
|
||||
/// 🔴 **This row is the one place the operator can read user content, and it
|
||||
/// is here because a reporter chose to put it here.** The relay stores
|
||||
/// ciphertext it cannot open, so it cannot examine a reported story on its own
|
||||
/// account. A report carrying only an accusation would give an operator nothing
|
||||
/// but the assertion, which makes moderation either credulous or useless — so
|
||||
/// the reporter's own decrypted copy travels with it, and the client is
|
||||
/// required to say so before sending (§6.25).
|
||||
///
|
||||
/// ⚠️ **Not exposed on any read route.** There is deliberately no endpoint that
|
||||
/// returns these. Reports are read out of band by whoever operates the server;
|
||||
/// a route would be a way to ask the relay what somebody reported, which is a
|
||||
/// question nobody but the operator gets to ask.
|
||||
final class ContentReport: Model, @unchecked Sendable {
|
||||
static let schema = "content_reports"
|
||||
|
||||
@ID(custom: "id", generatedBy: .database) var id: Int?
|
||||
/// Who filed it.
|
||||
@Field(key: "reporter_id") var reporterID: Int
|
||||
/// Who it is about.
|
||||
@Field(key: "about_id") var aboutID: Int
|
||||
/// `story` or `message` — which surface it came from.
|
||||
@Field(key: "kind") var kind: String
|
||||
/// A short enumerated reason.
|
||||
@Field(key: "reason") var reason: String
|
||||
/// The reporter's decrypted copy. May be empty: a reporter may decline to
|
||||
/// include it, and a report without it is weaker rather than invalid.
|
||||
@Field(key: "content") var content: String
|
||||
/// The reporter's own words, if they added any.
|
||||
@Field(key: "note") var note: String
|
||||
@Field(key: "created_at") var createdAt: Int
|
||||
|
||||
init() {}
|
||||
|
||||
/// The reasons a client may send. Enumerated rather than free text so the
|
||||
/// column stays sortable and a client cannot smuggle a third field in here.
|
||||
static let reasons: Set<String> = [
|
||||
"abuse", "harassment", "spam", "impersonation", "csam", "other",
|
||||
]
|
||||
static let kinds: Set<String> = ["story", "message"]
|
||||
|
||||
/// The largest copy a client may attach.
|
||||
///
|
||||
/// ⚠️ A bound, because this is unencrypted user text on the operator's disk
|
||||
/// and the route is authenticated but otherwise open. Large enough for a
|
||||
/// caption and a note, far too small to use as storage.
|
||||
static let maximumContent = 8_192
|
||||
}
|
||||
|
||||
extension Routes {
|
||||
|
||||
/// §6.25 — file a report.
|
||||
///
|
||||
/// ⚠️ The reported account is never told, here or anywhere else. A report
|
||||
/// that told its subject who filed it would be most dangerous to exactly
|
||||
/// the people most likely to need it.
|
||||
func submitReport(_ request: Request, now: Int) async throws -> Response {
|
||||
let body = request.body.data.map { Data(buffer: $0) } ?? Data()
|
||||
let caller = try await authenticate(
|
||||
request, body: body, path: "/e2e/moderation/report", now: now)
|
||||
|
||||
struct Submission: Content {
|
||||
let about: Int
|
||||
let kind: String
|
||||
let reason: String
|
||||
let content: String?
|
||||
let note: String?
|
||||
}
|
||||
let submission = try request.content.decode(Submission.self)
|
||||
|
||||
guard ContentReport.kinds.contains(submission.kind) else {
|
||||
throw WireError("bad_kind", "Unknown report kind.", 400)
|
||||
}
|
||||
guard ContentReport.reasons.contains(submission.reason) else {
|
||||
throw WireError("bad_reason", "Unknown report reason.", 400)
|
||||
}
|
||||
// ⚠️ Reporting yourself is not a thing, and allowing it would let an
|
||||
// account fill the table without another party being involved.
|
||||
guard submission.about != caller.userID else {
|
||||
throw WireError("bad_subject", "You cannot report yourself.", 400)
|
||||
}
|
||||
let content = String((submission.content ?? "").prefix(ContentReport.maximumContent))
|
||||
let note = String((submission.note ?? "").prefix(ContentReport.maximumContent))
|
||||
|
||||
let report = ContentReport()
|
||||
report.reporterID = caller.userID
|
||||
report.aboutID = submission.about
|
||||
report.kind = submission.kind
|
||||
report.reason = submission.reason
|
||||
report.content = content
|
||||
report.note = note
|
||||
report.createdAt = now
|
||||
try await report.save(on: request.db)
|
||||
|
||||
// Nothing about the outcome is promised. A report is a request to
|
||||
// somebody else, and telling the reporter "we have actioned this" when
|
||||
// nobody has looked at it yet would be a lie with a nice tone.
|
||||
return try .json(["ok": true])
|
||||
}
|
||||
}
|
||||
|
||||
/// ⚠️ `content` is `.sql(raw: "text")` rather than `.string`: a caption plus a
|
||||
/// note can exceed whatever a driver picks for a default varchar, and a report
|
||||
/// truncated by the schema is evidence quietly thrown away.
|
||||
struct CreateContentReport: AsyncMigration {
|
||||
func prepare(on database: any Database) async throws {
|
||||
try await database.schema(ContentReport.schema)
|
||||
.field("id", .int, .identifier(auto: true))
|
||||
.field("reporter_id", .int, .required)
|
||||
.field("about_id", .int, .required)
|
||||
.field("kind", .string, .required)
|
||||
.field("reason", .string, .required)
|
||||
.field("content", .sql(raw: "text"), .required)
|
||||
.field("note", .sql(raw: "text"), .required)
|
||||
.field("created_at", .int, .required)
|
||||
.create()
|
||||
// An operator's question is "what has been reported about this
|
||||
// account", so that is what gets the index.
|
||||
if let sql = database as? any SQLDatabase {
|
||||
try? await sql.raw("""
|
||||
CREATE INDEX IF NOT EXISTS content_reports_about
|
||||
ON content_reports (about_id)
|
||||
""").run()
|
||||
}
|
||||
}
|
||||
|
||||
func revert(on database: any Database) async throws {
|
||||
try await database.schema(ContentReport.schema).delete()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import Crypto
|
||||
import Fluent
|
||||
import Foundation
|
||||
import Testing
|
||||
import Vapor
|
||||
|
||||
@testable import FlamenetRelay
|
||||
|
||||
/// Reporting content (spec §6.25).
|
||||
///
|
||||
/// 🔴 **A report is the one place the operator can read user content, and it is
|
||||
/// readable because the reporter chose to send it.** The relay holds ciphertext
|
||||
/// it cannot open, so a report carrying only an accusation would give an
|
||||
/// operator nothing but the assertion. Everything here is about that row being
|
||||
/// exactly what the reporter meant to file, and nothing more.
|
||||
@Suite("content reports")
|
||||
struct ContentReportTests {
|
||||
|
||||
private let now = 1_700_000_000
|
||||
|
||||
private func body(about: Int, kind: String = "story", reason: String = "abuse",
|
||||
content: String = "what they posted", note: String = "") -> Data {
|
||||
Data("""
|
||||
{"about":\(about),"kind":"\(kind)","reason":"\(reason)",
|
||||
"content":"\(content)","note":"\(note)"}
|
||||
""".utf8)
|
||||
}
|
||||
|
||||
private func file(_ h: RelayHarness, as d: RelayHarness.TestDevice,
|
||||
_ payload: Data) async throws -> Response {
|
||||
let req = h.request(.POST, "/e2e/moderation/report", as: d, body: payload, now: now)
|
||||
return try await h.routes.submitReport(req, now: now)
|
||||
}
|
||||
|
||||
@Test("a report is stored with the reporter's copy")
|
||||
func aReportIsStored() async throws {
|
||||
try await withRelay { h in
|
||||
let d = h.device(user: 1, id: "aaaa-0001")
|
||||
_ = try await h.register(d, on: h.db)
|
||||
let response = try await file(h, as: d, body(about: 2))
|
||||
#expect(response.status == .ok)
|
||||
|
||||
let stored = try await ContentReport.query(on: h.db).all()
|
||||
#expect(stored.count == 1)
|
||||
#expect(stored[0].reporterID == 1)
|
||||
#expect(stored[0].aboutID == 2)
|
||||
#expect(stored[0].content == "what they posted")
|
||||
}
|
||||
}
|
||||
|
||||
/// ⚠️ A reporter may decline to include the content. The report is weaker,
|
||||
/// not invalid — refusing it would mean the only way to report something is
|
||||
/// to hand over a copy, which is a worse bargain than the one §6.25 makes.
|
||||
@Test("a report with no content is still accepted")
|
||||
func anEmptyReportIsAccepted() async throws {
|
||||
try await withRelay { h in
|
||||
let d = h.device(user: 1, id: "aaaa-0001")
|
||||
_ = try await h.register(d, on: h.db)
|
||||
let response = try await file(h, as: d, body(about: 2, content: ""))
|
||||
#expect(response.status == .ok)
|
||||
#expect(try await ContentReport.query(on: h.db).count() == 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// ⚠️ Enumerated, so the column stays sortable and a client cannot smuggle
|
||||
/// a third field through the reason.
|
||||
@Test("an unknown reason is refused")
|
||||
func anUnknownReasonIsRefused() async throws {
|
||||
try await withRelay { h in
|
||||
let d = h.device(user: 1, id: "aaaa-0001")
|
||||
_ = try await h.register(d, on: h.db)
|
||||
// ⚠️ Asserting the CODE, not merely that something threw. With the
|
||||
// signing path wrong these tests passed on `unauthorized`, which
|
||||
// is a WireError too — a green test proving nothing about reasons.
|
||||
var code = ""
|
||||
do { _ = try await file(h, as: d, body(about: 2, reason: "whatever")) }
|
||||
catch let e as WireError { code = e.code }
|
||||
#expect(code == "bad_reason")
|
||||
#expect(try await ContentReport.query(on: h.db).count() == 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("an unknown kind is refused")
|
||||
func anUnknownKindIsRefused() async throws {
|
||||
try await withRelay { h in
|
||||
let d = h.device(user: 1, id: "aaaa-0001")
|
||||
_ = try await h.register(d, on: h.db)
|
||||
var code = ""
|
||||
do { _ = try await file(h, as: d, body(about: 2, kind: "diary")) }
|
||||
catch let e as WireError { code = e.code }
|
||||
#expect(code == "bad_kind")
|
||||
}
|
||||
}
|
||||
|
||||
/// ⚠️ Reporting yourself would let one account fill the table without any
|
||||
/// other party being involved.
|
||||
@Test("reporting yourself is refused")
|
||||
func reportingYourselfIsRefused() async throws {
|
||||
try await withRelay { h in
|
||||
let d = h.device(user: 1, id: "aaaa-0001")
|
||||
_ = try await h.register(d, on: h.db)
|
||||
var code = ""
|
||||
do { _ = try await file(h, as: d, body(about: 1)) }
|
||||
catch let e as WireError { code = e.code }
|
||||
#expect(code == "bad_subject")
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔴 Unauthenticated reporting would let anybody fill an operator's table
|
||||
/// with content attributed to accounts they do not hold.
|
||||
@Test("an unauthenticated report is refused")
|
||||
func anUnauthenticatedReportIsRefused() async throws {
|
||||
try await withRelay { h in
|
||||
let payload = body(about: 2)
|
||||
let req = h.anonymousRequest(.POST, "/e2e/moderation/report", body: payload)
|
||||
await #expect(throws: Error.self) {
|
||||
_ = try await h.routes.submitReport(req, now: now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ⚠️ Bounded: this is unencrypted user text on the operator's disk, and
|
||||
/// the route is authenticated but otherwise open.
|
||||
@Test("an oversized copy is truncated rather than refused")
|
||||
func anOversizedCopyIsTruncated() async throws {
|
||||
try await withRelay { h in
|
||||
let d = h.device(user: 1, id: "aaaa-0001")
|
||||
_ = try await h.register(d, on: h.db)
|
||||
let huge = String(repeating: "a", count: ContentReport.maximumContent + 500)
|
||||
_ = try await file(h, as: d, body(about: 2, content: huge))
|
||||
let stored = try await ContentReport.query(on: h.db).all()
|
||||
#expect(stored[0].content.count == ContentReport.maximumContent)
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔴 **No read route exists, and that is deliberate.** An endpoint
|
||||
/// returning reports would be a way to ask the relay what somebody
|
||||
/// reported — a question nobody but the operator gets to ask. This test
|
||||
/// exists so that adding one is a conscious act that breaks it.
|
||||
@Test("nothing on the wire can read reports back")
|
||||
func reportsAreNotReadable() async throws {
|
||||
try await withRelay { h in
|
||||
let d = h.device(user: 1, id: "aaaa-0001")
|
||||
_ = try await h.register(d, on: h.db)
|
||||
_ = try await file(h, as: d, body(about: 2))
|
||||
// Asserted against the ROUTER rather than by making a request: the
|
||||
// point is that no route returning reports exists at all, so adding
|
||||
// one has to break this deliberately.
|
||||
let readRoutes = h.app.routes.all.filter {
|
||||
$0.path.map(\.description).joined(separator: "/").contains("moderation")
|
||||
&& $0.method != .POST
|
||||
}
|
||||
#expect(readRoutes.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user