Skip to content
Closed
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
7 changes: 4 additions & 3 deletions services/api/src/abuse/anon-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export const ANON_TOKEN_TTL_SECONDS = 24 * 60 * 60

export const ANON_TOKEN_REPORT_CAP = 5

export const ANON_REPORT_CAP_MESSAGE =
"This anonymous session has reached its report limit. Sign in to continue."

/** base64url never contains ".", so the separator cannot collide with the id or the HMAC. */
const TOKEN_SEP = "."

Expand Down Expand Up @@ -108,9 +111,7 @@ export function assertUnderReportCap(
cap: number = ANON_TOKEN_REPORT_CAP,
): { remaining: number } {
if (row.reportCount >= cap) {
throw AppError.rateLimited(
"This anonymous session has reached its report limit. Sign in to continue.",
)
throw AppError.rateLimited(ANON_REPORT_CAP_MESSAGE)
}
return { remaining: cap - row.reportCount }
}
Expand Down
23 changes: 14 additions & 9 deletions services/api/src/abuse/ip-rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,24 +12,29 @@ import type { CounterStore } from "./counter-store.js"

export const IP_HARD_LIMIT_PER_HOUR = 10

/** Higher soft cap reserved for a future known-clean-ASN classifier; unused until `classifyIpAllowance` consults it. */
export const IP_SOFT_LIMIT_PER_HOUR = 50

export const IP_WINDOW_SECONDS = 60 * 60
const IP_WINDOW_SECONDS = 60 * 60

const IP_COUNTER_PREFIX = "abuse:ip:"

const IPV6_PREFIX_HEXTETS = 4

const UNKNOWN_IP_BUCKET = "unknown"

const IPV4_MAPPED_IPV6_RE = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/

const IPV4_RE = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/

const LEADING_ZEROS_RE = /^0+(?=.)/

/** A missing IP normalizes to a stable "unknown" bucket so it is still limited instead of bypassing. */
export function normalizeIp(ip: string | undefined | null): string {
const raw = (ip ?? "").trim().toLowerCase()
if (raw === "") return "unknown"
if (raw === "") return UNKNOWN_IP_BUCKET

const mapped = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(raw)
const mapped = IPV4_MAPPED_IPV6_RE.exec(raw)
if (mapped) return mapped[1]!

if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(raw)) return raw
if (IPV4_RE.test(raw)) return raw

if (raw.includes(":")) {
return ipv6Prefix64(raw)
Expand All @@ -46,14 +51,14 @@ function ipv6Prefix64(addr: string): string {
const prefix: string[] = []
for (let i = 0; i < IPV6_PREFIX_HEXTETS; i++) {
const h = hextets[i] ?? "0"
const trimmed = h.replace(/^0+(?=.)/, "")
const trimmed = h.replace(LEADING_ZEROS_RE, "")
prefix.push(trimmed === "" ? "0" : trimmed)
}
return `${prefix.join(":")}::/64`
}

// The hard cap for every IP today: no GeoIP/ASN database ships. This is the one place to wire an ASN
// lookup that grants IP_SOFT_LIMIT_PER_HOUR to known-clean residential or shared-NAT ranges.
// lookup that grants a higher cap to known-clean residential or shared-NAT ranges.
export function classifyIpAllowance(_normalizedIp: string): number {
return IP_HARD_LIMIT_PER_HOUR
}
Expand Down
18 changes: 14 additions & 4 deletions services/api/src/abuse/slur-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ const DIGIT_LEET: Readonly<Record<string, string>> = {
}
const SYMBOL_LEET: Readonly<Record<string, string>> = { "!": "i", "|": "i", "@": "a" }

const LETTER_RE = /[a-z]/i

const SEPARATOR_BETWEEN_CHARS_RE = /([a-z0-9])[._\-*]+([a-z0-9])/gi

const SPACED_OUT_RUN_RE = /\b[a-z0-9](?: [a-z0-9])+\b/gi

const SPACE_RE = / /g

const SLUR_MESSAGE = "This contains language that isn't allowed."

function deLeet(s: string): string {
let out = ""
for (let i = 0; i < s.length; i++) {
Expand All @@ -54,7 +64,7 @@ function deLeet(s: string): string {
}
const digit = DIGIT_LEET[ch]
if (digit !== undefined) {
const adjacentToLetter = /[a-z]/i.test(s.charAt(i - 1)) || /[a-z]/i.test(s.charAt(i + 1))
const adjacentToLetter = LETTER_RE.test(s.charAt(i - 1)) || LETTER_RE.test(s.charAt(i + 1))
out += adjacentToLetter ? digit : ch
continue
}
Expand All @@ -69,9 +79,9 @@ function deobfuscate(text: string): string {
let prev: string
do {
prev = collapsed
collapsed = collapsed.replace(/([a-z0-9])[._\-*]+([a-z0-9])/gi, "$1$2")
collapsed = collapsed.replace(SEPARATOR_BETWEEN_CHARS_RE, "$1$2")
} while (collapsed !== prev)
return collapsed.replace(/\b[a-z0-9](?: [a-z0-9])+\b/gi, (run) => run.replace(/ /g, ""))
return collapsed.replace(SPACED_OUT_RUN_RE, (run) => run.replace(SPACE_RE, ""))
}

// Combining marks survive NFKD as separate code points and format characters (zero-width joiners, soft
Expand All @@ -96,6 +106,6 @@ export function containsSlur(text: string | null | undefined): boolean {

export function assertNoSlur(text: string | null | undefined, field = "body"): void {
if (containsSlur(text)) {
throw AppError.validation({ [field]: "This contains language that isn't allowed." })
throw AppError.validation({ [field]: SLUR_MESSAGE })
}
}
64 changes: 34 additions & 30 deletions services/api/src/adapters/abuse-checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { AppError } from "@civfix/shared"
import type { AbuseChecks, NearDuplicateResult } from "@civfix/shared/interfaces"
import type { LatLng } from "@civfix/shared"
import { haversineKm } from "@civfix/shared"
import { fetchJsonWithTimeout } from "./http-fetch.js"
import { fetchJsonWithTimeout, type FetchJsonResult } from "./http-fetch.js"

export type PerceptualHashFn = (buffer: Uint8Array) => Promise<string>

Expand Down Expand Up @@ -34,6 +34,9 @@ const TURNSTILE_TIMEOUT_MS = 4000

const GPS_MAX_KM = 50

const FNV1A_32_OFFSET_BASIS = 0x811c9dc5
const FNV1A_32_PRIME = 0x01000193

interface TurnstileVerifyResponse {
success?: boolean
hostname?: string
Expand Down Expand Up @@ -79,17 +82,7 @@ export class RealAbuseChecks implements AbuseChecks {
body,
},
})
if (!result.ok) {
const wrapped = AppError.internal(
result.kind === "http"
? `Turnstile verification returned HTTP ${result.status}`
: result.kind === "body"
? "Turnstile verification returned a non-JSON body"
: "Turnstile verification request failed",
)
if (result.kind !== "http") (wrapped as { cause?: unknown }).cause = result.error
throw wrapped
}
if (!result.ok) throw turnstileFailure(result)

const json = result.json
if (json.success !== true) return false
Expand All @@ -98,23 +91,22 @@ export class RealAbuseChecks implements AbuseChecks {
this.log("Turnstile token rejected: unexpected hostname", { hostname: json.hostname })
return false
}
if (expect?.action !== undefined) {
if (json.action === undefined || json.action === "") {
if (!this.turnstileActionNoticeLogged) {
this.turnstileActionNoticeLogged = true
this.log("Turnstile token carried no action; binding not enforced (soft-enforce)", {
expected: expect.action,
})
}
} else if (json.action !== expect.action) {
this.log("Turnstile token rejected: action mismatch", {
expected: expect.action,
actual: json.action,
return expect?.action === undefined || this.actionAccepted(json.action, expect.action)
}

private actionAccepted(actual: string | undefined, expected: string): boolean {
if (actual === undefined || actual === "") {
if (!this.turnstileActionNoticeLogged) {
this.turnstileActionNoticeLogged = true
this.log("Turnstile token carried no action; binding not enforced (soft-enforce)", {
expected,
})
return false
}
return true
}
return true
if (actual === expected) return true
this.log("Turnstile token rejected: action mismatch", { expected, actual })
return false
}

private hostnameAccepted(hostname: string | undefined): boolean {
Expand Down Expand Up @@ -179,13 +171,25 @@ export class RealAbuseChecks implements AbuseChecks {
}
}

function turnstileFailure(result: Exclude<FetchJsonResult<unknown>, { ok: true }>): AppError {
const wrapped = AppError.internal(
result.kind === "http"
? `Turnstile verification returned HTTP ${result.status}`
: result.kind === "body"
? "Turnstile verification returned a non-JSON body"
: "Turnstile verification request failed",
)
if (result.kind !== "http") (wrapped as { cause?: unknown }).cause = result.error
return wrapped
}

function fnv1a64Hex(buffer: Uint8Array): string {
let lo = 0x811c9dc5
let hi = 0x811c9dc5
let lo = FNV1A_32_OFFSET_BASIS
let hi = FNV1A_32_OFFSET_BASIS
for (let i = 0; i < buffer.length; i++) {
const b = buffer[i] ?? 0
lo = Math.imul(lo ^ b, 0x01000193) >>> 0
hi = Math.imul(hi ^ (b ^ (i & 0xff)), 0x01000193) >>> 0
lo = Math.imul(lo ^ b, FNV1A_32_PRIME) >>> 0
hi = Math.imul(hi ^ (b ^ (i & 0xff)), FNV1A_32_PRIME) >>> 0
}
const toHex8 = (n: number): string => (n >>> 0).toString(16).padStart(8, "0")
return toHex8(hi) + toHex8(lo)
Expand Down
16 changes: 12 additions & 4 deletions services/api/src/adapters/chat-presence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export const PRESENCE_TTL_MS = 90_000

const PRESENCE_KEY_TTL_SECONDS = 7200

const PRESENCE_KEY_PREFIX = "presence:"

/** UUIDs never contain "::", so splitting on the first occurrence is unambiguous. */
const SEP = "::"

Expand Down Expand Up @@ -59,6 +61,12 @@ function userOf(memberId: string): string {
return idx === -1 ? memberId : memberId.slice(0, idx)
}

// ZREMRANGEBYSCORE max bound: the "(" makes it exclusive, so an entry seen exactly at the cutoff survives
// as it does in the in-memory prune.
function staleScoreBound(now: number): string {
return `(${now - PRESENCE_TTL_MS}`
}

function distinctUsers(members: string[]): string[] {
return [...new Set(members.map(userOf))].sort()
}
Expand Down Expand Up @@ -97,7 +105,7 @@ export class RedisChatPresence implements ChatPresence {
constructor(private readonly redis: RedisClient) {}

private key(cleanupId: string): string {
return `presence:${cleanupId}`
return `${PRESENCE_KEY_PREFIX}${cleanupId}`
}

async join(cleanupId: string, connId: string, userId: string): Promise<PresenceJoinResult> {
Expand All @@ -107,7 +115,7 @@ export class RedisChatPresence implements ChatPresence {
// cannot be perturbed by an interleaving command.
const replies = await this.redis
.multi()
.zremrangebyscore(key, "-inf", `(${now - PRESENCE_TTL_MS}`)
.zremrangebyscore(key, "-inf", staleScoreBound(now))
.zadd(key, now, member(userId, connId))
.expire(key, PRESENCE_KEY_TTL_SECONDS)
.zrange(key, 0, -1)
Expand All @@ -125,7 +133,7 @@ export class RedisChatPresence implements ChatPresence {
const replies = await this.redis
.multi()
.zrem(key, member(userId, connId))
.zremrangebyscore(key, "-inf", `(${now - PRESENCE_TTL_MS}`)
.zremrangebyscore(key, "-inf", staleScoreBound(now))
.zrange(key, 0, -1)
.exec()
const members = presenceMembers(replies)
Expand All @@ -151,7 +159,7 @@ export class RedisChatPresence implements ChatPresence {
const now = Date.now()
const replies = await this.redis
.multi()
.zremrangebyscore(key, "-inf", `(${now - PRESENCE_TTL_MS}`)
.zremrangebyscore(key, "-inf", staleScoreBound(now))
.zrange(key, 0, -1)
.exec()
return distinctUsers(presenceMembers(replies))
Expand Down
4 changes: 3 additions & 1 deletion services/api/src/adapters/chat-pubsub.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { attachRedisErrorHandler, type RedisClient } from "./redis.js"
import { RefCountedSubscriptions } from "./ref-counted-subscriptions.js"

const CHAT_CHANNEL_PREFIX = "chat:"

export type ChatPubSubHandler = (payload: string) => void

export interface ChatPubSub {
Expand All @@ -10,7 +12,7 @@ export interface ChatPubSub {
}

export function chatChannel(cleanupId: string): string {
return `chat:${cleanupId}`
return `${CHAT_CHANNEL_PREFIX}${cleanupId}`
}

export class RedisChatPubSub implements ChatPubSub {
Expand Down
11 changes: 8 additions & 3 deletions services/api/src/adapters/chat-send-dedupe.redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export interface RedisSendDedupeOptions {
sleep?: (ms: number) => Promise<void>
}

const MS_PER_SECOND = 1000

function wholeSecondsToMs(seconds: number): number {
return Math.max(1, Math.ceil(seconds)) * MS_PER_SECOND
}

const realSleep = (ms: number): Promise<void> =>
new Promise((resolve) => {
const timer = setTimeout(resolve, ms)
Expand All @@ -35,9 +41,8 @@ export class RedisSendDedupeStore implements SendDedupeStore {

constructor(redis: RedisClient, opts: RedisSendDedupeOptions = {}) {
this.redis = redis
this.ttlMs = Math.max(1, Math.ceil(opts.ttlSeconds ?? SEND_DEDUPE_TTL_SECONDS)) * 1000
this.pendingTtlMs =
Math.max(1, Math.ceil(opts.pendingTtlSeconds ?? SEND_DEDUPE_PENDING_TTL_SECONDS)) * 1000
this.ttlMs = wholeSecondsToMs(opts.ttlSeconds ?? SEND_DEDUPE_TTL_SECONDS)
this.pendingTtlMs = wholeSecondsToMs(opts.pendingTtlSeconds ?? SEND_DEDUPE_PENDING_TTL_SECONDS)
this.inFlightAttempts = opts.inFlightAttempts ?? SEND_DEDUPE_INFLIGHT_ATTEMPTS
this.sleep = opts.sleep ?? realSleep
}
Expand Down
Loading
Loading