Skip to content
Merged
6 changes: 6 additions & 0 deletions src/config/yaml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,12 @@ export const getInviteTargetAttemptLimits = () => ({
blockDuration: toSeconds(86400), // 24 hours
})

export const getConsentLogAttemptLimits = () => ({
points: 20,
duration: toSeconds(3600), // 1 hour
blockDuration: toSeconds(3600), // 1 hour
})

/**
* Card top-up checkout links, per account.
*
Expand Down
114 changes: 114 additions & 0 deletions src/domain/consent-log/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { ValidationError } from "@domain/shared"

// A consent record submitted by the getflash.io/invite landing page (ENG-568).
// The page has displayed a transactional/marketing consent flow since launch
// and POSTed it to /consent/log — an endpoint that did not exist, so every
// record was silently dropped. This validator is the endpoint's admission
// gate: the request body is anonymous, unauthenticated web input, so every
// field is bounded and everything unknown is discarded rather than stored.

const MAX_VERSION = 64
const MAX_URL = 2048
const MAX_USER_AGENT = 1024
const MAX_TIMESTAMP = 64
const MAX_TOKEN = 128
const MAX_PURPOSE = 256
const MAX_FREQUENCY = 64

// The invite token is secret-bearing (it redeems the invite). It is accepted
// here so the consent record can be tied to its invite, but callers must
// store only its hash — the same rule the invites collection follows.
export type ConsentLogSubmission = {
version: string
sourceUrl?: string
userAgent?: string
clientTimestamp?: string
token?: string
consents: {
transactional: { optedIn: boolean; purpose?: string; frequency?: string }
marketing: { optedIn: boolean; purpose?: string; frequency?: string }
}
}

const boundedString = (
value: unknown,
max: number,
field: string,
): string | undefined | ValidationError => {
if (value === undefined || value === null) return undefined
if (typeof value !== "string") return new ValidationError(`${field} must be a string`)
if (value.length > max) return new ValidationError(`${field} exceeds ${max} chars`)
return value
}

const checkedConsentLeg = (
value: unknown,
field: string,
): ConsentLogSubmission["consents"]["transactional"] | ValidationError => {
if (typeof value !== "object" || value === null) {
return new ValidationError(`${field} must be an object`)
}
const leg = value as Record<string, unknown>
if (typeof leg.optedIn !== "boolean") {
return new ValidationError(`${field}.optedIn must be a boolean`)
}
const purpose = boundedString(leg.purpose, MAX_PURPOSE, `${field}.purpose`)
if (purpose instanceof ValidationError) return purpose
const frequency = boundedString(leg.frequency, MAX_FREQUENCY, `${field}.frequency`)
if (frequency instanceof ValidationError) return frequency
return { optedIn: leg.optedIn, purpose, frequency }
}

export const checkedToConsentLogSubmission = (
body: unknown,
): ConsentLogSubmission | ValidationError => {
if (typeof body !== "object" || body === null) {
return new ValidationError("body must be a JSON object")
}
const raw = body as Record<string, unknown>

if (typeof raw.version !== "string" || raw.version.length === 0) {
return new ValidationError("version is required")
}
if (raw.version.length > MAX_VERSION) {
return new ValidationError(`version exceeds ${MAX_VERSION} chars`)
}

if (typeof raw.consents !== "object" || raw.consents === null) {
return new ValidationError("consents is required")
}
const consents = raw.consents as Record<string, unknown>
const transactional = checkedConsentLeg(
consents.transactional,
"consents.transactional",
)
if (transactional instanceof ValidationError) return transactional
const marketing = checkedConsentLeg(consents.marketing, "consents.marketing")
if (marketing instanceof ValidationError) return marketing

// The page sends the page URL as `page`; accept `sourceUrl` too so the
// field name can converge without breaking either sender.
const sourceUrl = boundedString(raw.sourceUrl ?? raw.page, MAX_URL, "sourceUrl")
if (sourceUrl instanceof ValidationError) return sourceUrl
const userAgent = boundedString(raw.userAgent, MAX_USER_AGENT, "userAgent")
if (userAgent instanceof ValidationError) return userAgent
// Same convergence path as sourceUrl: the page sends `timestamp` today;
// accept the canonical `clientTimestamp` too.
const clientTimestamp = boundedString(
raw.clientTimestamp ?? raw.timestamp,
MAX_TIMESTAMP,
"timestamp",
)
if (clientTimestamp instanceof ValidationError) return clientTimestamp
const token = boundedString(raw.token, MAX_TOKEN, "token")
if (token instanceof ValidationError) return token

return {
version: raw.version,
sourceUrl,
userAgent,
clientTimestamp,
token,
consents: { transactional, marketing },
}
}
1 change: 1 addition & 0 deletions src/domain/rate-limit/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export class UserCodeAttemptIdentifierRateLimiterExceededError extends RateLimit
export class UserCodeAttemptIpRateLimiterExceededError extends RateLimiterExceededError {}
export class UserCodeAttemptBlockedCountryIpRateLimiterExceededError extends RateLimiterExceededError {}
export class CreateDeviceAccountIpRateLimiterExceededError extends RateLimiterExceededError {}
export class ConsentLogIpRateLimiterExceededError extends RateLimiterExceededError {}
export class UserLoginIpRateLimiterExceededError extends RateLimiterExceededError {}
export class UserLoginIdentifierRateLimiterExceededError extends RateLimiterExceededError {}
export class InvoiceCreateRateLimiterExceededError extends RateLimiterExceededError {}
Expand Down
11 changes: 11 additions & 0 deletions src/domain/rate-limit/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
getConsentLogAttemptLimits,
getFailedLoginAttemptPerIpLimits,
getFailedLoginAttemptPerLoginIdentifierLimits,
getFygaroCheckoutCreateAttemptLimits,
Expand All @@ -14,6 +15,7 @@ import {
} from "@config"

import {
ConsentLogIpRateLimiterExceededError,
FygaroCheckoutCreateRateLimiterExceededError,
FygaroTopupAllowanceRateLimiterExceededError,
InviteCreateRateLimiterExceededError,
Expand All @@ -40,6 +42,7 @@ export const RateLimitPrefix = {
inviteCreate: "invite_daily",
inviteTarget: "invite_target",
fygaroCheckoutCreate: "fygaro_checkout_create",
consentLog: "consent_log_ip",
fygaroTopupAllowance: "fygaro_topup_allowance",
} as const

Expand Down Expand Up @@ -115,4 +118,12 @@ export const RateLimitConfig: { [key: string]: RateLimitConfig } = {
limits: getFygaroTopupAllowanceAttemptLimits(),
error: FygaroTopupAllowanceRateLimiterExceededError,
},
// Public, unauthenticated write endpoint (consent evidence from the
// getflash.io/invite page). Per-IP: one legitimate submission per accept
// click, so the ceiling is far above any real use and far below abuse.
consentLog: {
key: RateLimitPrefix.consentLog,
limits: getConsentLogAttemptLimits(),
error: ConsentLogIpRateLimiterExceededError,
},
}
175 changes: 175 additions & 0 deletions src/servers/consent-log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import cors from "cors"
import express, { NextFunction, Request, Response } from "express"

import { UNSECURE_IP_FROM_REQUEST_OBJECT } from "@config"
import { parseIps } from "@domain/accounts-ips"
import { checkedToConsentLogSubmission } from "@domain/consent-log"
import { RateLimitConfig } from "@domain/rate-limit"
import { RateLimiterExceededError } from "@domain/rate-limit/errors"
import { ValidationError } from "@domain/shared"
import { baseLogger } from "@services/logger"
import { ConsentLogRepository } from "@services/mongoose/models/consent-log"
import { consumeLimiter } from "@services/rate-limit"
import { hashToken } from "@utils"

// POST /consent/log — compliance evidence from the getflash.io/invite landing
// page (ENG-568). The page has sent these records since launch with the call
// wrapped fail-open; this endpoint finally persists them.
//
// Anonymous by design: the submitter is an invitee with no session. That makes
// this a public unauthenticated write, so it is deliberately narrow —
// tiny body cap, strict field validation, per-IP rate limit, and the invite
// token stored only as a hash. Responses carry no body on success (204) and
// no internals on failure.

const consentLogRouter = express.Router({ caseSensitive: true })

// The submitter is a browser on getflash.io posting cross-origin to
// api.flashapp.me — without CORS the preflight fails and the (fail-open)
// page call silently drops every record. No credentials involved.
// Both the apex and www hosts are allowed: if a visitor reaches the invite
// page at www.getflash.io (or the www→apex redirect hasn't happened before
// the page fires its fail-open POST), an apex-only allowlist would silently
// drop those records — the original incident, resurrected for that subset.
consentLogRouter.use(cors({ origin: ["https://getflash.io", "https://www.getflash.io"] }))

// Resolves the caller's IP and consumes the per-IP rate limit BEFORE
// express.json() gets a chance to run. This has to be router-level
// middleware, not logic inside the /log handler: express.json() routes a
// parse failure (oversized body -> "entity.too.large", malformed JSON ->
// "entity.parse.failed") straight to the error handler below, skipping the
// route handler entirely. If the limiter were only consumed inside that
// handler, an attacker could flood this endpoint at wire speed with
// oversized/malformed bodies and never touch the limiter — only a
// well-formed submission would ever be capped. Consuming here means every
// request that reaches this router is charged against the bucket
// regardless of whether its body goes on to parse.
const enforceConsentLogRateLimit = async (
req: Request,
res: Response,
next: NextFunction,
) => {
// Same header conventions as the rest of the public surface: x-real-ip
// from ingress, x-forwarded-for as fallback (graphql-main-server does the
// same). A missing IP would collapse the rate limit into one global
// bucket, so treat it as an infrastructure fault, not an open door.
const ipString = UNSECURE_IP_FROM_REQUEST_OBJECT
? req.ip
: req.headers["x-real-ip"] || req.headers["x-forwarded-for"]
const ip = parseIps(ipString)
if (!ip) {
baseLogger.error(
{ headers: { "x-real-ip": req.headers["x-real-ip"] } },
"consent-log request has no resolvable client IP",
)
return res.status(503).json({ error: "temporarily unavailable" })
}

const limited = await consumeLimiter({
rateLimitConfig: RateLimitConfig.consentLog,
keyToConsume: ip,
})
if (limited instanceof RateLimiterExceededError) {
return res.status(429).json({ error: "too many requests" })
}
if (limited instanceof Error) {
// Rate-limit store fault. Evidence collection must not become a way to
// probe infrastructure health — refuse rather than fail open on a public
// write endpoint.
baseLogger.error({ error: limited }, "consent-log rate limiter unavailable")
return res.status(503).json({ error: "temporarily unavailable" })
}

// Hand the resolved IP to the route handler (it's stored on the record)
// without re-deriving it from headers a second time.
res.locals.consentLogIp = ip
return next()
}

consentLogRouter.use(enforceConsentLogRateLimit)

consentLogRouter.use(express.json({ limit: "8kb" }))

consentLogRouter.post("/log", async (req, res) => {
const ip = res.locals.consentLogIp as IpAddress

const submission = checkedToConsentLogSubmission(req.body)
if (submission instanceof ValidationError) {
return res.status(400).json({ error: submission.message })
}

try {
await ConsentLogRepository.create({
version: submission.version,
consents: submission.consents,
// Raw token never touches disk — same rule the invites collection
// follows (it stores tokenHash only).
inviteTokenHash: submission.token ? hashToken(submission.token) : undefined,
sourceUrl: submission.sourceUrl,
userAgent: submission.userAgent,
clientTimestamp: submission.clientTimestamp,
ip,
receivedAt: new Date(),
})
} catch (err) {
baseLogger.error({ error: err }, "consent-log persist failed")
return res.status(500).json({ error: "could not record consent" })
}

return res.status(204).send()
})

// Body-parser failures (malformed JSON, oversized body) would otherwise fall
// through to Express's default handler, which answers with HTML — including a
// stack trace outside production. Keep the contract: JSON only, no internals.
consentLogRouter.use(
(
err: Error & { type?: string },
_req: Request,
res: Response,
// Express identifies an error handler by its arity — the 4th arg is
// required even though this terminal handler never calls it.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_next: NextFunction,
) => {
if (err.type === "entity.too.large") {
return res.status(413).json({ error: "body too large" })
}
if (err.type === "entity.parse.failed") {
return res.status(400).json({ error: "invalid body" })
}
baseLogger.error({ error: err }, "consent-log unhandled error")
return res.status(500).json({ error: "could not record consent" })
},
)

// Used by the app-level PinoHttp access logger (graphql-server.ts). pino-http
// re-evaluates customProps at response finish — by then express.json() inside
// this router has populated req.body, so logging the raw body would write the
// raw invite token to the access logs on every status path (204/400/429/503).
// Same rule as the invites collection: the raw token never touches disk.
// (authRouter dodges this by mounting BEFORE PinoHttp; the consent router
// mounts after it so the public write path still gets an access-log line.)
//
// Gated on res.locals.consentLogIp, not the request URL. Two rounds of
// URL-string matching here each shipped a distinct bypass that leaked a raw
// token into the access log: req.url loses the "/consent" mount prefix that
// Express strips before dispatching into the router (originalUrl fixed
// that), and matching "/consent/" with a trailing slash to avoid swallowing
// a sibling route like "/consent-status" let a bare "POST /consent" (no
// trailing slash, no "/log") straight through unredacted — Express still
// dispatches that request into this router's middleware (so the rate
// limiter runs and express.json() parses a real body) before it falls
// through to a 404 with no matching route, and pino-http logs the
// now-populated req.body at response finish regardless of the 404.
// consentLogIp is the one signal immune to path-shape bypasses: it's set
// unconditionally by enforceConsentLogRateLimit, router-level middleware
// that runs for every request Express dispatches into this router no
// matter which path it targets or whether any route inside ultimately
// matches it.
export const redactConsentBodyForLog = (
res: { locals?: { consentLogIp?: unknown } },
body: unknown,
) => (res.locals?.consentLogIp !== undefined ? "[consent body redacted]" : body)

export default consentLogRouter
20 changes: 18 additions & 2 deletions src/servers/graphql-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { parseUnknownDomainErrorFromUnknown } from "@domain/shared"
import { MAXIMUM_QUERY_COMPLEXITY, createComplexityPlugin } from "./plugins/complexity"

import authRouter from "./authorization"
import consentLogRouter, { redactConsentBodyForLog } from "./consent-log"
import kratosCallback from "./event-handlers/kratos"
import { apiKeyRateLimitMiddleware } from "./middlewares/api-key-rate-limit"
import healthzHandler from "./middlewares/healthz"
Expand Down Expand Up @@ -156,13 +157,18 @@ export const startApolloServer = async ({
PinoHttp({
logger: graphqlLogger,
wrapSerializers: true,
customProps: (req) => {
customProps: (req, res) => {
/* eslint @typescript-eslint/ban-ts-comment: "off" */
// @ts-ignore-next-line no-implicit-any error
const account = req["gqlContext"]?.domainAccount
return {
// pino-http evaluates customProps again at response finish, after
// the consent router's express.json() has populated req.body — so
// the raw invite token would land in the access logs without this
// redaction. Guarded by tests in
// test/flash/unit/servers/consent-log.spec.ts.
// @ts-ignore-next-line no-implicit-any error
"body": req["body"],
"body": redactConsentBodyForLog(res, req.body),
// @ts-ignore-next-line no-implicit-any error
"token.sub": req["token"]?.sub,
// @ts-ignore-next-line no-implicit-any error
Expand Down Expand Up @@ -194,6 +200,16 @@ export const startApolloServer = async ({
}),
)

// Public consent-evidence intake from the getflash.io/invite page
// (ENG-568). Anonymous callers, so it mounts only on the public server and
// BEFORE the JWT middleware below — but AFTER PinoHttp, so a public
// unauthenticated write path shows up in the access logs. The logged body
// for /consent requests is redacted (redactConsentBodyForLog above) so the
// raw invite token never reaches the logs.
if (type === "main") {
app.use("/consent", consentLogRouter)
}

const secret = jwksRsa.expressJwtSecret(getJwksArgs()) as GetVerificationKey // https://github.com/auth0/express-jwt/issues/288#issuecomment-1122524366

app.use(idempotencyMiddleware) // TODO: only needed for public endpoint
Expand Down
Loading
Loading