package aviso import ( "database/sql" "errors" "log/slog" "net/http" "net/url" "strings" "time" ) // Config configures New. DB, PrivateKey, Contact and Origin are // required. type Config struct { // DB is the app's writer. Schema must have been applied. DB *sql.DB // PrivateKey is the VAPID private key: unpadded base64url, 32-byte // P-256 scalar, as cmd/aviso-key prints it. Provisioned, never // minted here — see ErrEmptyPrivateKey. PrivateKey string // Contact is the VAPID "sub" claim — a mailto: or https: URL a push // service may use to reach the operator about abuse. Contact string // Origin is the app's external origin, scheme included, for // csrf.SameOrigin on the mutating handlers. Origin string // Concurrency bounds in-flight sends across the whole Service. // 0 means 32. Concurrency int Logger *slog.Logger } // Subscription is what the browser hands the app: the push service's // endpoint and the two keys RFC 8291 encrypts to. type Subscription struct { Endpoint string P256dh string Auth string } // Stored is one enrolled device: a Subscription plus its row identity. // Revision changes on every re-subscribe, and Send matches on it so a // slow send cannot prune a subscription the browser refreshed // meanwhile. type Stored struct { ID string Subject string VAPIDKeyID string Revision int64 Subscription } // ErrOwnedElsewhere is Subscribe's refusal to move an endpoint between // subjects: a second account on the same browser must re-enrol, not // silently take over the first account's device. var ErrOwnedElsewhere = errors.New("aviso: endpoint is enrolled by another subject") // ErrKeyMismatch marks a Result for a row enrolled under a VAPID key // other than this Service's: it cannot be signed for, so it is skipped // rather than sent to fail. var ErrKeyMismatch = errors.New("aviso: subscription was enrolled under a different VAPID key") // Service is the wired addon. Build one per process and share it: the // concurrency bound lives on it. type Service struct { cfg Config pub string // keyID is SHA-256 of the public point; rows carry it, and Send // skips rows that do not match rather than signing for them with // a key the browser never subscribed to. keyID string // wireContact is Contact as webpush-go wants it: it prefixes // "mailto:" itself to anything not https:, so handing it the // mailto: form verbatim would produce "mailto:mailto:…". wireContact string client *http.Client // built by newClient (ssrf.go); tests may replace it sem chan struct{} now func() time.Time // dbTimeout bounds the store update after a push service answers. // It is independent of the caller's context on purpose (see // settle) and bounded on purpose: the writer is one connection. dbTimeout time.Duration } // New validates cfg and returns a ready *Service. func New(cfg Config) (*Service, error) { if cfg.DB == nil { return nil, errors.New("aviso: Config.DB is required") } pub, keyID, err := parsePrivateKey(cfg.PrivateKey) if err != nil { return nil, err } if err := validateContact(cfg.Contact); err != nil { return nil, err } if err := validateOrigin(cfg.Origin); err != nil { return nil, err } if cfg.Concurrency <= 0 { cfg.Concurrency = 32 } if cfg.Logger == nil { cfg.Logger = slog.Default() } return &Service{ cfg: cfg, pub: pub, keyID: keyID, wireContact: strings.TrimPrefix(cfg.Contact, "mailto:"), client: newClient(), sem: make(chan struct{}, cfg.Concurrency), now: time.Now, dbTimeout: 5 * time.Second, }, nil } // validateContact admits "mailto:
" with a non-empty address // containing "@", or an https URL with a host. The push service signs // nothing with it but may write to it about abuse, and webpush-go // would happily sign a JWT whose subject is "mailto:". func validateContact(c string) error { const msg = "aviso: Config.Contact must be mailto:
or an https: URL with a host" switch { case strings.HasPrefix(c, "mailto:"): addr := strings.TrimPrefix(c, "mailto:") if addr == "" || !strings.Contains(addr, "@") || strings.ContainsAny(addr, " \t\r\n") { return errors.New(msg) } return nil case strings.HasPrefix(c, "https://"): u, err := url.Parse(c) if err != nil || u.Host == "" || u.User != nil { return errors.New(msg) } return nil } return errors.New(msg) } // validateOrigin requires exactly an origin: scheme, host, optional // port, nothing else. csrf.SameOrigin compares the browser's Origin // header to this string byte for byte when Sec-Fetch-Site is absent, // so a trailing slash or a path would refuse every legitimate POST. func validateOrigin(o string) error { const msg = "aviso: Config.Origin must be an absolute origin like https://app.example.com (no path, no trailing slash)" u, err := url.Parse(o) if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.Opaque != "" || u.Scheme+"://"+u.Host != o { return errors.New(msg) } return nil } // PublicKeyString is the applicationServerKey the browser subscribes // with: unpadded base64url of the uncompressed P-256 point. func (s *Service) PublicKeyString() string { return s.pub }