dispute

package
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 18, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Overview

Package dispute is the appeal path for a blocked destination, and the queue an instance owner works through.

It is designed as an attack surface rather than decorated as one afterwards, because that is literally what it is: a stranger picks a URL, and this feature puts it in front of the person who administers the box. Four properties carry that, and each is enforced by something other than good intentions.

**Only a low-confidence refusal can be disputed.** The tier is re-derived server-side from the URL, by the same evaluator the link surfaces use, so it is never a field a caller supplies. An unappealable refusal — a private address, a forbidden scheme — has no dispute path at all, and neither does the embedded tier: the party those refusals protect is not the party appealing, and an owner who could approve 169.254.169.254 on request would have turned this queue into the SSRF the validator exists to refuse.

**Nothing here fetches anything.** No preview, no screenshot, no favicon, no "does it still resolve" check. TestTheQueueFetchesNothing parses this package and the handlers that serve it and fails on any outbound-HTTP symbol, because a preview fetch is exactly that same SSRF arriving as a convenience feature. Since M32 there is one thing that leaves the box on this path and it is not a fetch of the destination: filing re-judges the URL, and on an instance that has named a reputation feed, judging sends the destination to that feed. The difference is the one the SSRF argument turns on — the address contacted is the operator's configured endpoint, never the attacker-chosen destination — and it is disclosed at /feeds and in the docs rather than left implicit.

**The destination is stored inert.** Defanged once, on the way in, the rule audit_logs.metadata has followed since M30 — a value that cannot be rendered live is one no consumer written later can render live by forgetting.

**A decision writes no permission anywhere.** Allowing removes one row from blocked_destinations; there is no row anybody can write that makes a destination acceptable, and 01500 has no allow column on purpose. M32 added the one decision that deletes nothing: an allowed dispute about a feed verdict is itself the override, read only by internal/link's feed step, which is the last check and the one every built-in tier has already returned before. So an allow still cannot reach the unappealable tier, the embedded list or the runtime blocklist — see liftableRules.

Index

Constants

View Source
const (
	// StatusOpen is waiting for a decision.
	StatusOpen = "open"
	// StatusAllowed means the owner removed the entry that refused it.
	StatusAllowed = "allowed"
	// StatusUpheld means the owner looked and left the refusal standing.
	StatusUpheld = "upheld"
)

Statuses. A dispute is filed open and leaves in exactly one of two directions.

View Source
const (
	// KindFiled tells the people who can review that a dispute has arrived.
	KindFiled = "dispute.filed"
	// KindDecided tells the person who filed one what was decided.
	KindDecided = "dispute.decided"
)

Notification kinds. Both are about the dispute rather than about the refusal: whoever typed the URL already learned of the block synchronously, in the 422. What needs delivering later is that something is waiting, and what came of it.

View Source
const (
	ActionDisputeAllowed = audit.ActionDisputeAllowed
	ActionDisputeUpheld  = audit.ActionDisputeUpheld
)

Audit actions. The decisions are recorded and the filing is not, and that asymmetry is deliberate: a filing's whole record is the dispute row, which carries who and when and outlives the account. A decision has an effect *outside* that row — an entry gone from the instance-wide blocklist — and the audit log is the only place that effect is otherwise visible. Declared in internal/audit since M45 and referred to here, so the vocabulary has one home and anything enumerating it from that package is complete (F18). Kept as names in this package because they are this package's verbs and its callers read better for it.

View Source
const (
	// CodeNotDisputable is the whole of "unappealable and embedded-tier refusals
	// have no dispute path at all".
	CodeNotDisputable = "not_disputable"
	// CodeNotBlocked means nothing refuses that destination — usually because
	// somebody already lifted the entry.
	CodeNotBlocked = "not_blocked"
)

Codes a refusal to file carries, on the `url` field.

Field errors rather than a sentinel, because every other refusal about a destination in this program is one and a client already branches on the shape. Nothing is hidden by naming the cause: whoever is filing received the tier and the rule in the 422 that refused their link a moment ago, so the vocabulary is one they have already been handed.

View Source
const PermDecide = "destinations.decide"

PermDecide guards acting on a dispute: allowing it or upholding it.

Non-delegable to an API key, on D18's second limb: holding it lets a key widen its own reach. An allow removes a host from the instance-wide low-confidence list, after which every destination under that host becomes creatable — including by the key that removed it. That is a key turning "may not link there" into "may link there" by an action it takes itself, which is the shape D18 names.

This is also how D98's second constraint is built. *"API access is read-only for disputes; a change requires a person"* is implemented as this scope sitting in auth.NonDelegableScopes, **not** as a check on what kind of credential is calling: the inherited Permissions rule says anything branching on credential type outside that map and D43 is a defect. So the endpoints below authorize on a permission like every other endpoint, and nothing in this package asks whether the caller holds a session. It keeps *"every UI feature has API support"* true as well — the decide endpoints exist, are documented and are replayed by the contract test, and they refuse a key, exactly as apikeys.* already does. A surface that exists and refuses is a different thing from a surface that does not exist.

auth.NonDelegableScopes is the only thing that enforces it, so reversing this is deleting one map entry. See decisions.md.

View Source
const PermFile = link.PermCreate

PermFile guards filing one.

Deliberately the permission that would have let the person create the link in the first place, rather than a new one. A dispute is the second half of an attempt somebody was already allowed to make; requiring a separate grant would mean the refusal message ("the instance owner can review it") is a lie for everyone who can create links, which is the population it is shown to.

View Source
const PermReview = "destinations.review"

PermReview guards reading the queue: listing it, counting it, inspecting what is in it.

**Delegable to an API key**, and it was not until M45 (D98). Reading matches neither limb of D18 — the queue discloses who filed a dispute and a defanged host, never an address or a network prefix, and holding it widens nobody's reach. What used to make it non-delegable was that one permission guarded both halves, and the deciding half does widen reach; splitting them is what lets the reading half be what it always was.

Held instance-wide, by a person the principal appointed, or by the principal itself. It is no longer granted to the owner *role*, and that is F15: on an instance with more than one organization — one registration away under LINKCTRL_SIGNUP_MODE=open — every owner read every dispute on the box.

Variables

This section is empty.

Functions

This section is empty.

Types

type Config

type Config struct {
	// Judge decides which tier refused a destination. Required: without it there
	// is no way to tell an appealable refusal from an unappealable one, and
	// guessing in either direction is worse than refusing to start.
	Judge Judge
	// Audit records the two decisions. Nil records nothing.
	Audit audit.Recorder
	// Notify carries both messages. Nil tells nobody, which is what a test that
	// does not care about delivery gets.
	Notify Notifier
	Log    *slog.Logger
}

Config is what a Service needs.

type Dispute

type Dispute struct {
	ID uuid.UUID `json:"id"`
	// Host is the destination's host as it was typed, defanged. Never rendered as
	// a link, never handed to anything that fetches.
	Host string `json:"host_defanged"`
	// BlockedHost is the blocklist entry an allow would delete, defanged. Empty
	// when no entry produced the refusal.
	//
	// It is a separate field from Host because it is routinely a different value:
	// the list matches on label boundaries, so a dispute about
	// login.evil.example is a dispute about the row that says evil.example. Every
	// surface that offers Allow renders this rather than Host, which is the whole
	// of F33 — a queue that shows one host while the button acts on another is
	// asking somebody to approve a decision they have not been told.
	BlockedHost string `json:"blocked_host_defanged,omitempty"`
	// Destination is the attempted URL, defanged.
	Destination string `json:"destination_defanged"`
	// ReasonCode is M30's "<tier>.<rule>". Always a low_confidence code.
	ReasonCode string `json:"reason_code"`
	Status     string `json:"status"`
	// FiledBy is the address of whoever filed it, snapshotted at write time so it
	// survives the account.
	FiledBy   string    `json:"filed_by"`
	CreatedAt time.Time `json:"created_at"`
	// DecidedBy and DecidedAt are set once a decision is recorded.
	DecidedBy string     `json:"decided_by,omitempty"`
	DecidedAt *time.Time `json:"decided_at,omitempty"`
	// Liftable says whether an allow could do anything. False for a refusal that
	// no list row produced — a homograph, credentials in the URL — where there is
	// nothing to delete and the only honest decision is to uphold or to change
	// the code. The page draws its buttons from this rather than making the owner
	// discover it by clicking.
	Liftable bool `json:"liftable"`
}

Dispute is one appeal, as every reader sees it.

Every string here that came from whoever filed it is inert. Destination is defanged in the column; Host is defanged on the way out, because the plain value is the key a decision acts on and is worth exactly one representation in the database. There is no free-text field at all — see the migration.

type Filter

type Filter struct {
	Cursor   string
	Limit    int32
	OpenOnly bool
}

Filter is a page request.

type Judge

type Judge interface {
	Judge(ctx context.Context, raw string) (link.Verdict, error)
}

Judge is internal/link's tier evaluation, as this package needs it.

An interface declared by the consumer, so a test can answer with a table instead of standing up the link service and a blocklist behind it — and so this package's dependency on link is one method wide.

type Notifier

type Notifier interface {
	Notify(ctx context.Context, userID uuid.UUID, e notify.Event) error
	EveryReviewer(ctx context.Context) ([]notify.Recipient, error)
	// RecipientByID and Mail are the outcome email (D1's addendum). Mail is a
	// no-op on an instance with no mailer, so this package never asks whether
	// one is configured — in-app delivery is the baseline and the mail is the
	// addition, in that order, at every call site.
	RecipientByID(ctx context.Context, userID uuid.UUID) (notify.Recipient, error)
	Mail(ctx context.Context, to notify.Recipient, template string, data map[string]string) error
}

Notifier is internal/notify's writing half, as this package needs it.

Reviewers, not "owners of an organization": the queue is instance-wide, so the people to tell about a new dispute are everybody who could act on it. Until M45 that was the same method spelled `EveryOwner`, which meant every owner of every organization because no other set existed — see EveryReviewer, and F137.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service files, lists and decides disputes.

func NewService

func NewService(pool *pgxpool.Pool, cfg Config) (*Service, error)

func (*Service) Allow

func (s *Service) Allow(ctx context.Context, actor *auth.Identity, id uuid.UUID) (*Dispute, error)

Allow removes the entry that refused the destination, and closes the dispute.

The deletion is scoped to the row the low-confidence list matched when the dispute was filed, recorded on the dispute then and rendered on the control that triggers this. It can reach nothing else: the embedded tier is a compiled file and the unappealable tier has no row anywhere, so "acts only on the runtime low-confidence list" is a property of there being nothing else to act on.

Two refusals it declines rather than pretending to lift:

  • a rule no row produced. A homograph is computed from the URL every time, so deleting nothing and reporting success would tell the owner the destination is now usable when it is not.
  • a row sourced from LINKCTRL_DESTINATION_BLOCKLIST. Boot reconciles that variable back into the table, so allowing it would last until the next restart and then silently revert — the one failure mode a moderation decision must not have. The operator is told to edit the variable.

func (*Service) CountOpen

func (s *Service) CountOpen(ctx context.Context, actor *auth.Identity) (int64, error)

CountOpen is what the queue's heading says there is to do.

func (*Service) File

func (s *Service) File(ctx context.Context, actor *auth.Identity, rawURL string) (*Dispute, error)

File records a dispute about a destination that was refused.

The destination is re-judged here rather than trusted from the caller, and that is the mechanism behind "creatable only from a low-confidence refusal": no request field names a tier, so no request can claim one. It re-judges against the list *as it is now*, so a host removed since the refusal produces "that destination is not refused any more" instead of a dispute about nothing.

No audit record. The refusal being argued about already wrote one, and a second `destination.blocked` per dispute would inflate exactly the count an operator reads to decide whether a heuristic is worth keeping.

func (*Service) List

func (s *Service) List(ctx context.Context, actor *auth.Identity, f Filter) (*domain.Page[Dispute], error)

List answers one page of the queue, newest first.

Instance-wide, like the list it argues with. Scoping it to the reader's own organization would hide rows the same reader is nonetheless deciding for, because an allow removes an entry every organization on the instance is refused by. The permission is what bounds who sees it — and since D98 that permission is held instance-wide by named people rather than by every owner of every organization, which is the difference between the queue being instance-wide and its readership being accidental.

func (*Service) Uphold

func (s *Service) Uphold(ctx context.Context, actor *auth.Identity, id uuid.UUID) (*Dispute, error)

Uphold leaves the refusal standing and closes the dispute.

It changes no list and is still an audit event. "The owner looked and said no" is a different fact from "nobody has looked yet", and a queue that only records the decisions which changed something cannot tell them apart.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL