Skip to content

Commit 08237b8

Browse files
authored
fix(labeler): notification contact-resolution, unsubscribe, and verification-trust fixes (review round 1) (#2116)
* fix(labeler): honor RFC 8058 one-click unsubscribe query param A one-click unsubscribe POST carries the recipient hash (c) in the List-Unsubscribe header URL's query string and only List-Unsubscribe=One-Click in the body. The POST parser read c from the form body only, so a one-click request returned success without suppressing the recipient. Read c (and the confirm token) from the body first, falling back to the query. * fix(labeler): resolve package contacts for release-subject notices A release subject's rkey is slug:version, but contactTargetFromUri passed the whole rkey as the package slug, so getPackage missed the package and its security[]/authors[] contacts were skipped in favor of the publisher-profile fallback. Parse the parent package slug (before the version delimiter) so a release notice reaches the package's contacts; a package rkey and a malformed rkey both degrade safely. * fix(labeler): alert operators when a takedown notice has no contact An emergency takedown issuance that resolves no publisher contact recorded only a generic undeliverable notification row, so the signal that manual outreach is needed was easy to miss. Emit a dedicated takedown-no-contact operational event (severity high) plus its operator-alert outbox row on that path. A takedown retract and a takedown that does resolve a contact emit nothing, and a deduped replay does not re-emit. * fix(labeler): gate double-opt-in bypass on trusted, current verification A publisher's verification claim upgraded a contact past double opt-in on the strength of any in-force claim. Verification claims are self-assertable — the aggregator indexes any issuer, including self-issued ones — so a publisher could name a victim address in its profile, self-issue a claim, and have an unconfirmed notice sent there. Upgrade only on a claim whose issuer is in the configured trust set, that is unexpired, and whose bound displayName still matches the publisher's current identity. No issuer is trusted by default, so every address falls back to double opt-in. The publisher view carries a current displayName but no handle (the aggregator's identity-event ingestion is unbuilt), so the binding checks displayName only; handle-binding is deferred until the view carries a handle. * fix(labeler): make takedown no-contact alert recoverable The takedown-no-contact operator alert was emitted only on the first pass's send outcome, after the undeliverable notifications row had committed. A transient failure of that fire-and-forget event write lost the alert permanently: a deferred replay dedups on the existing notifications row and never reached the emit again. Key the alert on its own operational_events row instead, and run the check on every takedown issuance (including the dedup replay path), so a replay after a failed emit recovers the signal while a normal replay does not duplicate it. * fix(labeler): scope unsubscribe query fallback, keep confirm body-only The one-click query fallback was applied to the confirm and not-me POST parsers too, so a scanner POSTing the original confirmation link URL (with c and t in the query) could confirm an address without the user submitting the form. Restrict the query fallback to the RFC 8058 one-click unsubscribe path; confirm and not-me require their capability from the POST body. * fix(labeler): validate release rkey shape; make no-contact alert dedup atomic Contact resolution stripped a subject rkey at the first colon without checking the collection or the canonical release rkey shape, so a malformed colon-bearing subject could resolve a DIFFERENT package's contacts. Only strip a canonical release record (release collection + well-formed slug:version); anything else keeps its rkey and degrades to the publisher tier. The takedown-no-contact alert used an existence-check-then-insert with no uniqueness guard, so two concurrent replays could double-emit. Add a UNIQUE(action_id, event_type) index (migration 0012) and make the insert idempotent (ON CONFLICT DO NOTHING), with the outbox gated on the event being written; concurrency and replay now converge to exactly one event. * fix(labeler): scope dedup index to takedown-no-contact; require semver Migration 0012 created a GLOBAL unique index on (action_id, event_type), which would fail at migration time on any existing DB holding historical duplicate rows of other event types. Make it PARTIAL (WHERE event_type = 'takedown-no-contact') and point the insert's ON CONFLICT target at that predicate, so it stays safe on historical data while still deduping the alert. The release-rkey parse validated only the slug and a non-empty suffix, so 'gallery:not-semver' still resolved the gallery package. Validate the complete canonical shape (slug + percent-decoded semver version, mirroring the aggregator's parseReleaseRkey) before stripping; anything else degrades to the publisher tier.
1 parent 3692bbf commit 08237b8

7 files changed

Lines changed: 845 additions & 54 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
-- At-most-one `takedown-no-contact` operational event per action. That deferred
2+
-- alert is raised from a fire-and-forget tail with a check-then-insert, so a
3+
-- concurrent replay could double-emit; this unique index is the hard guarantee
4+
-- that collapses them to one row (paired with ON CONFLICT DO NOTHING on the
5+
-- insert).
6+
--
7+
-- PARTIAL, scoped to `takedown-no-contact` only: the prior schema allowed
8+
-- duplicate (action_id, event_type) rows and historical data may hold them for
9+
-- OTHER event types, which a global unique index would reject at migration time.
10+
-- Restricting the predicate to the one event type that needs the guarantee keeps
11+
-- the migration safe on existing data while still enforcing the dedup we require.
12+
CREATE UNIQUE INDEX idx_operational_events_takedown_no_contact
13+
ON operational_events(action_id, event_type)
14+
WHERE event_type = 'takedown-no-contact';

apps/labeler/src/notification-endpoints.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
* Safety properties:
1515
* - GET renders a confirmation page with a POST form; only POST mutates. An
1616
* email scanner's or link-prefetcher's automated GET therefore never
17-
* confirms, unsubscribes, or suppresses. The token/hash live in hidden form
18-
* fields, so the mutating POST carries them in the body, not the URL.
17+
* confirms, unsubscribes, or suppresses. The browser POST carries the
18+
* token/hash in hidden form fields; only the RFC 8058 one-click UNSUBSCRIBE
19+
* POST carries `c` in the header URL's query, so the query fallback is scoped
20+
* to that path — confirm and not-me require their capability from the body.
1921
* - CSRF: a cross-site POST cannot supply a valid recipient hash (or confirm
2022
* token) for a victim, so possession of the capability is the CSRF defense;
2123
* a custom header (unsendable from a plain email-client form) is not used.
@@ -114,18 +116,25 @@ async function readPostParams(
114116
request: Request,
115117
action: NotificationAction,
116118
): Promise<RequestParams> {
117-
let form: FormData;
119+
let form: FormData | null = null;
118120
try {
119121
form = await request.formData();
120122
} catch {
121-
return { recipientHash: "", token: "" };
123+
form = null;
122124
}
123-
const recipientHash = form.get("c");
124-
const token = form.get("t");
125-
return {
126-
recipientHash: typeof recipientHash === "string" ? recipientHash : "",
127-
token: action === "confirm" && typeof token === "string" ? token : "",
128-
};
125+
const bodyHash = form?.get("c");
126+
let recipientHash = typeof bodyHash === "string" ? bodyHash : "";
127+
// RFC 8058 one-click unsubscribe POSTs to the List-Unsubscribe header URL with
128+
// `c` only in its query string and `List-Unsubscribe=One-Click` in the body.
129+
// That query fallback is scoped to unsubscribe ONLY: confirm and not-me require
130+
// their capability from the POST body, so a scanner cannot confirm (or suppress
131+
// via not-me) an address by POSTing a link URL without the rendered form.
132+
if (action === "unsubscribe" && recipientHash.length === 0) {
133+
recipientHash = new URL(request.url).searchParams.get("c") ?? "";
134+
}
135+
const bodyToken = form?.get("t");
136+
const token = action === "confirm" && typeof bodyToken === "string" ? bodyToken : "";
137+
return { recipientHash, token };
129138
}
130139

131140
/**

apps/labeler/src/notification-triggers.ts

Lines changed: 203 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,15 @@
1616
* `notifications` row is skipped, so a Workflow-step retry or a mutation
1717
* replay does not re-notify. `sendNotification`'s notice claim closes the
1818
* residual concurrent race atomically.
19-
* - Verified-publisher skip: a publisher with an in-force verification claim
20-
* bypasses double opt-in (the notice goes out directly). The verification read
21-
* fails CLOSED — any error leaves the publisher on the normal confirmation
22-
* path, never looser.
19+
* - Verified-publisher skip: a publisher whose CURRENT identity is vouched for
20+
* by an in-force verification claim from a TRUSTED issuer bypasses double
21+
* opt-in (the notice goes out directly). A self-issued or otherwise untrusted
22+
* claim carries no authority — verification claims are self-assertable and the
23+
* aggregator indexes any issuer — so only a claim whose issuer is in the
24+
* configured trust set AND whose bound displayName still matches the
25+
* publisher's current identity upgrades a contact. The read fails CLOSED, and
26+
* with no trusted issuer configured (the default) NOTHING upgrades: every
27+
* address stays on the normal confirmation path.
2328
*
2429
* All notice copy is public-safe: subject line, label effect, the assessment's
2530
* public summary, and the public assessment + reconsideration URLs. No findings,
@@ -41,6 +46,11 @@ import type {
4146
SendContext,
4247
} from "./notification-send.js";
4348
import { sendNotification } from "./notification-send.js";
49+
import {
50+
buildOperationalEventInsert,
51+
buildOutboxInsert,
52+
newOperationalEventId,
53+
} from "./operational-events.js";
4454
import { getOperatorActionById } from "./operator-actions.js";
4555
import { getLabelDefinition } from "./policy.js";
4656
import type { ContactTarget } from "./publisher-contact.js";
@@ -60,9 +70,18 @@ export interface NotifyDeps {
6070
serviceUrl: string;
6171
/** The monitored reconsideration URL from the moderation policy. */
6272
reconsiderationUrl: string;
73+
/** DIDs whose verification claims may upgrade a contact past double opt-in.
74+
* Verification claims are self-assertable (the aggregator indexes any issuer),
75+
* so a claim upgrades only when its issuer is in this set. Empty (the default)
76+
* trusts no issuer, so every address stays on the confirmation path. */
77+
trustedVerificationIssuers?: ReadonlySet<string>;
6378
now?: () => Date;
6479
}
6580

81+
/** No verification issuer is trusted — the conservative default for
82+
* {@link NotifyDeps.trustedVerificationIssuers}. */
83+
const NO_TRUSTED_ISSUERS: ReadonlySet<string> = new Set();
84+
6685
/**
6786
* Build the production {@link NotifyDeps} from the Worker env: the real Cloudflare
6887
* Email Sending adapter over the `EMAIL` binding, the aggregator client over the
@@ -82,6 +101,10 @@ export async function createNotifyDeps(env: Env): Promise<NotifyDeps> {
82101
pepper,
83102
serviceUrl: env.LABELER_SERVICE_URL,
84103
reconsiderationUrl: moderationPolicy.contact.reconsiderationUrl,
104+
// No verification issuer is trusted to bypass double opt-in: the labeler
105+
// issues no first-party verification and the aggregator indexes any
106+
// self-asserted claim, so every address goes through confirmation.
107+
trustedVerificationIssuers: NO_TRUSTED_ISSUERS,
85108
};
86109
}
87110

@@ -327,6 +350,97 @@ export async function notifyEmergencyTakedown(
327350
target,
328351
emergencyNoticeContent(deps, input),
329352
);
353+
// A takedown ISSUANCE (not a retract) that resolves no contact is the most
354+
// consequential delivery failure — the undeliverable audit row alone is easy to
355+
// miss. Raise a dedicated operator alert, keyed on ITS OWN operational_events
356+
// row rather than the notifications-row dedup: this runs on every issuance,
357+
// including a replay that `runTrigger` short-circuited on the existing
358+
// notifications row, so a first pass whose event write failed transiently
359+
// recovers on the next replay instead of losing the signal forever.
360+
if (!input.neg) await ensureTakedownNoContactAlert(deps, input.actionId, input.uri);
361+
}
362+
363+
/** The operator-alert channel for the emergency no-contact signal — mirrors the
364+
* emergency-action alert channel in `console-mutation-api`. */
365+
const OPERATOR_ALERT_CHANNEL = "deployment-alert";
366+
367+
/**
368+
* Raise the `takedown-no-contact` operational event + its outbox row when a
369+
* takedown issuance resolved no contact and the alert is not already recorded.
370+
* Idempotent and recoverable: the operational_events row (not the notifications
371+
* row) is the dedup key, so a first pass whose event batch failed re-emits on a
372+
* later replay, while a normal replay does not duplicate. The insert is guarded
373+
* by the `(action_id, event_type)` unique index + ON CONFLICT DO NOTHING, so two
374+
* concurrent replays that both pass the existence check still converge to one
375+
* event (the outbox is gated on the event being written, so a conflict leaves no
376+
* orphan). Fire-and-forget — an error is swallowed and logged, never propagated
377+
* into the deferred tail (a later replay retries anyway, the event still absent).
378+
*/
379+
async function ensureTakedownNoContactAlert(
380+
deps: NotifyDeps,
381+
actionId: string,
382+
uri: string,
383+
): Promise<void> {
384+
try {
385+
if (!(await sourceResolvedNoContact(deps.db, actionId))) return;
386+
if (await takedownNoContactAlertExists(deps.db, actionId)) return;
387+
const now = (deps.now ?? (() => new Date()))();
388+
const eventId = newOperationalEventId();
389+
await deps.db.batch([
390+
buildOperationalEventInsert(deps.db, {
391+
id: eventId,
392+
eventType: "takedown-no-contact",
393+
severity: "high",
394+
actionId,
395+
subjectUri: uri,
396+
labelValue: "!takedown",
397+
payload: {
398+
reason:
399+
"Emergency takedown has no resolvable publisher contact; manual outreach required.",
400+
},
401+
now,
402+
idempotentTakedownNoContact: true,
403+
}),
404+
buildOutboxInsert(deps.db, {
405+
eventId,
406+
channel: OPERATOR_ALERT_CHANNEL,
407+
now,
408+
gateOnEventPresent: true,
409+
}),
410+
]);
411+
} catch (error) {
412+
console.error("[notifications] takedown no-contact alert failed", {
413+
actionId,
414+
error: error instanceof Error ? error.message : String(error),
415+
});
416+
}
417+
}
418+
419+
/** Whether this operator action's notification resolved to NO contact: the send
420+
* path records that (and only that) undeliverable case with a NULL
421+
* `recipient_hash` (suppressed/declined carry a hash). */
422+
async function sourceResolvedNoContact(db: D1Database, actionId: string): Promise<boolean> {
423+
const row = await db
424+
.prepare(
425+
`SELECT 1 FROM notifications
426+
WHERE source_type = 'operator' AND source_id = ? AND recipient_hash IS NULL LIMIT 1`,
427+
)
428+
.bind(actionId)
429+
.first<{ 1: number }>();
430+
return row !== null;
431+
}
432+
433+
/** Whether a `takedown-no-contact` alert already exists for this action — the
434+
* event's dedup key, decoupled from the notifications-row dedup. */
435+
async function takedownNoContactAlertExists(db: D1Database, actionId: string): Promise<boolean> {
436+
const row = await db
437+
.prepare(
438+
`SELECT 1 FROM operational_events
439+
WHERE action_id = ? AND event_type = 'takedown-no-contact' LIMIT 1`,
440+
)
441+
.bind(actionId)
442+
.first<{ 1: number }>();
443+
return row !== null;
330444
}
331445

332446
/** Reconsideration outcome notice (console `reconsiderations/:id/resolve`). Fired
@@ -491,7 +605,12 @@ async function runTrigger(
491605
logTrigger(source, target.did, "deduped");
492606
return true;
493607
}
494-
const verifiedPublisher = await isVerifiedPublisher(deps.aggregator, target.did, now());
608+
const verifiedPublisher = await isVerifiedPublisher(
609+
deps.aggregator,
610+
deps.trustedVerificationIssuers ?? NO_TRUSTED_ISSUERS,
611+
target.did,
612+
now(),
613+
);
495614
const ctx: SendContext = {
496615
db: deps.db,
497616
aggregator: deps.aggregator,
@@ -531,21 +650,45 @@ async function sourceAlreadyProcessed(
531650
}
532651

533652
/**
534-
* Whether the publisher holds an IN-FORCE verification claim — reuses the
535-
* history-context notion of "in force" (a claim whose `expiresAt` is absent or in
536-
* the future). FAILS CLOSED: a `null` view (redacted / unverified), an empty
537-
* claim set, or ANY read error returns `false`, so the address stays on the
538-
* stricter double-opt-in path.
653+
* Whether the publisher may bypass double opt-in on the strength of a TRUSTED
654+
* verification claim. A claim upgrades a contact only when it is
655+
* (a) issued by a DID in `trustedIssuers` — verification claims are
656+
* self-assertable and the aggregator indexes any issuer, so an untrusted or
657+
* self-issued claim carries no authority;
658+
* (b) in force — no expiry, or an expiry still in the future; and
659+
* (c) bound to the publisher's CURRENT identity — its `displayName` still
660+
* matches `getPublisher`'s live value, so a displayName drift since the
661+
* verification does not carry trust. The claim also binds a `handle`, but
662+
* the publisher view carries no current handle to compare it against (the
663+
* aggregator's identity-event ingestion is unbuilt), so handle-binding is
664+
* deferred until it does.
665+
* Note this vouches for the publisher's identity, not that they own the contact
666+
* address — the trust set is the operator's explicit decision to accept that.
667+
*
668+
* FAILS CLOSED: an empty trust set (the default), a `null` verification/publisher
669+
* view, an empty claim set, unknown current handle/displayName, or ANY read error
670+
* returns `false`, so the address stays on the stricter double-opt-in path.
539671
*/
540672
export async function isVerifiedPublisher(
541-
aggregator: Pick<AggregatorClient, "getPublisherVerification">,
673+
aggregator: Pick<AggregatorClient, "getPublisherVerification" | "getPublisher">,
674+
trustedIssuers: ReadonlySet<string>,
542675
did: string,
543676
now: Date,
544677
): Promise<boolean> {
678+
if (trustedIssuers.size === 0) return false;
545679
try {
546680
const state = await aggregator.getPublisherVerification(did);
547681
if (!state || state.verifications.length === 0) return false;
548-
return state.verifications.some((claim) => isInForce(claim.expiresAt, now));
682+
const trustedInForce = state.verifications.filter(
683+
(claim) => trustedIssuers.has(claim.issuer) && isInForce(claim.expiresAt, now),
684+
);
685+
if (trustedInForce.length === 0) return false;
686+
687+
const publisher = await aggregator.getPublisher(did);
688+
if (!publisher) return false;
689+
const displayName = readDisplayName(publisher.profile);
690+
if (displayName === undefined) return false;
691+
return trustedInForce.some((claim) => claim.displayName === displayName);
549692
} catch (error) {
550693
console.error("[notifications] verification read failed, using double opt-in", {
551694
did,
@@ -555,6 +698,14 @@ export async function isVerifiedPublisher(
555698
}
556699
}
557700

701+
/** The `displayName` a publisher-profile record carries, or undefined when the
702+
* profile is not a `{ displayName: string }`-shaped object. */
703+
function readDisplayName(profile: unknown): string | undefined {
704+
if (typeof profile !== "object" || profile === null) return undefined;
705+
const value = (profile as { displayName?: unknown }).displayName;
706+
return typeof value === "string" ? value : undefined;
707+
}
708+
558709
/** A claim is in force when it has no expiry or its expiry is still in the future
559710
* (mirrors `history-context`'s `isExpired`). */
560711
function isInForce(expiresAt: string | undefined, now: Date): boolean {
@@ -577,24 +728,57 @@ function assessmentUrl(serviceUrl: string, uri: string, cid?: string): string {
577728
/**
578729
* The `{ did, slug }` a notice's contact resolution needs, parsed from the
579730
* subject URI. A record URI (`at://did/collection/rkey`) yields the DID and the
580-
* rkey as the slug — for a package record the rkey IS the package slug (tier-1/2
581-
* resolve); for a release the rkey misses the package tiers and resolution falls
582-
* through to the DID-keyed publisher-profile contact (tier 3), which is the
583-
* reliable channel. A bare DID subject (publisher-level action) resolves by DID
584-
* alone. Anything unparseable returns null and the notice is skipped.
731+
* parent package slug: a package rkey IS the slug, and a canonical release rkey
732+
* is `slug:version`, so the slug is the part before the first `:`. That lets
733+
* `getPackage` reach the package's `security[]`/`authors[]` contacts (tier-1/2)
734+
* for a release subject instead of falling straight through to the DID-keyed
735+
* publisher profile (tier 3). A bare DID subject (publisher-level action)
736+
* resolves by DID alone. Anything unparseable returns null and the notice is
737+
* skipped.
585738
*/
586739
export function contactTargetFromUri(uri: string): ContactTarget | null {
587740
if (uri.startsWith("at://")) {
588-
const [did, , rkey] = uri.slice("at://".length).split("/");
741+
const [did, collection, rkey] = uri.slice("at://".length).split("/");
589742
if (did === undefined || did.length === 0) return null;
590-
return { did, slug: rkey ?? "" };
743+
return { did, slug: packageSlugFromRecord(collection, rkey ?? "") };
591744
}
592745
if (uri.startsWith("did:")) {
593746
return { did: uri, slug: uri.split(":").at(-1) ?? uri };
594747
}
595748
return null;
596749
}
597750

751+
// Canonical release-rkey shape, mirroring the aggregator's ingest validation
752+
// (`records-consumer`'s `parseReleaseRkey`): `<slug>:<semver>` with the version
753+
// percent-decoded before the semver check. Kept in sync by the pinned tests.
754+
const PACKAGE_SLUG_RE = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
755+
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
756+
757+
/**
758+
* The parent package slug a subject resolves against. Only a CANONICAL release
759+
* record — the release collection AND a `slug:version` rkey whose slug is
760+
* well-formed AND whose version is valid semver — is stripped to its package slug
761+
* (`gallery:1.2.0` → `gallery`). Every other subject keeps its rkey verbatim: a
762+
* package rkey IS the slug, and a non-release collection or a malformed
763+
* colon-bearing rkey (`gallery:not-semver`) stays whole so it can never strip to
764+
* a DIFFERENT package's slug — it misses at `getPackage` and resolution degrades
765+
* to the publisher tier.
766+
*/
767+
function packageSlugFromRecord(collection: string | undefined, rkey: string): string {
768+
if (collection !== NSID.packageRelease) return rkey;
769+
const delimiter = rkey.indexOf(":");
770+
if (delimiter <= 0 || delimiter === rkey.length - 1) return rkey;
771+
const slug = rkey.slice(0, delimiter);
772+
if (!PACKAGE_SLUG_RE.test(slug)) return rkey;
773+
let version: string;
774+
try {
775+
version = decodeURIComponent(rkey.slice(delimiter + 1));
776+
} catch {
777+
return rkey;
778+
}
779+
return SEMVER_RE.test(version) ? slug : rkey;
780+
}
781+
598782
function logTrigger(source: NotificationSource, did: string, outcome: string): void {
599783
console.log("[notifications]", {
600784
action: "trigger",

0 commit comments

Comments
 (0)