| 1 | package aviso |
| 2 | |
| 3 | import ( |
| 4 | "database/sql" |
| 5 | "errors" |
| 6 | "log/slog" |
| 7 | "net/http" |
| 8 | "net/url" |
| 9 | "strings" |
| 10 | "time" |
| 11 | ) |
| 12 | |
| 13 | // Config configures New. DB, PrivateKey, Contact and Origin are |
| 14 | // required. |
| 15 | type Config struct { |
| 16 | // DB is the app's writer. Schema must have been applied. |
| 17 | DB *sql.DB |
| 18 | // PrivateKey is the VAPID private key: unpadded base64url, 32-byte |
| 19 | // P-256 scalar, as cmd/aviso-key prints it. Provisioned, never |
| 20 | // minted here — see ErrEmptyPrivateKey. |
| 21 | PrivateKey string |
| 22 | // Contact is the VAPID "sub" claim — a mailto: or https: URL a push |
| 23 | // service may use to reach the operator about abuse. |
| 24 | Contact string |
| 25 | // Origin is the app's external origin, scheme included, for |
| 26 | // csrf.SameOrigin on the mutating handlers. |
| 27 | Origin string |
| 28 | // Concurrency bounds in-flight sends across the whole Service. |
| 29 | // 0 means 32. |
| 30 | Concurrency int |
| 31 | Logger *slog.Logger |
| 32 | } |
| 33 | |
| 34 | // Subscription is what the browser hands the app: the push service's |
| 35 | // endpoint and the two keys RFC 8291 encrypts to. |
| 36 | type Subscription struct { |
| 37 | Endpoint string |
| 38 | P256dh string |
| 39 | Auth string |
| 40 | } |
| 41 | |
| 42 | // Stored is one enrolled device: a Subscription plus its row identity. |
| 43 | // Revision changes on every re-subscribe, and Send matches on it so a |
| 44 | // slow send cannot prune a subscription the browser refreshed |
| 45 | // meanwhile. |
| 46 | type Stored struct { |
| 47 | ID string |
| 48 | Subject string |
| 49 | VAPIDKeyID string |
| 50 | Revision int64 |
| 51 | Subscription |
| 52 | } |
| 53 | |
| 54 | // ErrOwnedElsewhere is Subscribe's refusal to move an endpoint between |
| 55 | // subjects: a second account on the same browser must re-enrol, not |
| 56 | // silently take over the first account's device. |
| 57 | var ErrOwnedElsewhere = errors.New("aviso: endpoint is enrolled by another subject") |
| 58 | |
| 59 | // ErrKeyMismatch marks a Result for a row enrolled under a VAPID key |
| 60 | // other than this Service's: it cannot be signed for, so it is skipped |
| 61 | // rather than sent to fail. |
| 62 | var ErrKeyMismatch = errors.New("aviso: subscription was enrolled under a different VAPID key") |
| 63 | |
| 64 | // Service is the wired addon. Build one per process and share it: the |
| 65 | // concurrency bound lives on it. |
| 66 | type Service struct { |
| 67 | cfg Config |
| 68 | pub string |
| 69 | // keyID is SHA-256 of the public point; rows carry it, and Send |
| 70 | // skips rows that do not match rather than signing for them with |
| 71 | // a key the browser never subscribed to. |
| 72 | keyID string |
| 73 | // wireContact is Contact as webpush-go wants it: it prefixes |
| 74 | // "mailto:" itself to anything not https:, so handing it the |
| 75 | // mailto: form verbatim would produce "mailto:mailto:…". |
| 76 | wireContact string |
| 77 | client *http.Client // built by newClient (ssrf.go); tests may replace it |
| 78 | sem chan struct{} |
| 79 | now func() time.Time |
| 80 | // dbTimeout bounds the store update after a push service answers. |
| 81 | // It is independent of the caller's context on purpose (see |
| 82 | // settle) and bounded on purpose: the writer is one connection. |
| 83 | dbTimeout time.Duration |
| 84 | } |
| 85 | |
| 86 | // New validates cfg and returns a ready *Service. |
| 87 | func New(cfg Config) (*Service, error) { |
| 88 | if cfg.DB == nil { |
| 89 | return nil, errors.New("aviso: Config.DB is required") |
| 90 | } |
| 91 | pub, keyID, err := parsePrivateKey(cfg.PrivateKey) |
| 92 | if err != nil { |
| 93 | return nil, err |
| 94 | } |
| 95 | if err := validateContact(cfg.Contact); err != nil { |
| 96 | return nil, err |
| 97 | } |
| 98 | if err := validateOrigin(cfg.Origin); err != nil { |
| 99 | return nil, err |
| 100 | } |
| 101 | if cfg.Concurrency <= 0 { |
| 102 | cfg.Concurrency = 32 |
| 103 | } |
| 104 | if cfg.Logger == nil { |
| 105 | cfg.Logger = slog.Default() |
| 106 | } |
| 107 | return &Service{ |
| 108 | cfg: cfg, |
| 109 | pub: pub, |
| 110 | keyID: keyID, |
| 111 | wireContact: strings.TrimPrefix(cfg.Contact, "mailto:"), |
| 112 | client: newClient(), |
| 113 | sem: make(chan struct{}, cfg.Concurrency), |
| 114 | now: time.Now, |
| 115 | dbTimeout: 5 * time.Second, |
| 116 | }, nil |
| 117 | } |
| 118 | |
| 119 | // validateContact admits "mailto:<address>" with a non-empty address |
| 120 | // containing "@", or an https URL with a host. The push service signs |
| 121 | // nothing with it but may write to it about abuse, and webpush-go |
| 122 | // would happily sign a JWT whose subject is "mailto:". |
| 123 | func validateContact(c string) error { |
| 124 | const msg = "aviso: Config.Contact must be mailto:<address> or an https: URL with a host" |
| 125 | switch { |
| 126 | case strings.HasPrefix(c, "mailto:"): |
| 127 | addr := strings.TrimPrefix(c, "mailto:") |
| 128 | if addr == "" || !strings.Contains(addr, "@") || strings.ContainsAny(addr, " \t\r\n") { |
| 129 | return errors.New(msg) |
| 130 | } |
| 131 | return nil |
| 132 | case strings.HasPrefix(c, "https://"): |
| 133 | u, err := url.Parse(c) |
| 134 | if err != nil || u.Host == "" || u.User != nil { |
| 135 | return errors.New(msg) |
| 136 | } |
| 137 | return nil |
| 138 | } |
| 139 | return errors.New(msg) |
| 140 | } |
| 141 | |
| 142 | // validateOrigin requires exactly an origin: scheme, host, optional |
| 143 | // port, nothing else. csrf.SameOrigin compares the browser's Origin |
| 144 | // header to this string byte for byte when Sec-Fetch-Site is absent, |
| 145 | // so a trailing slash or a path would refuse every legitimate POST. |
| 146 | func validateOrigin(o string) error { |
| 147 | const msg = "aviso: Config.Origin must be an absolute origin like https://app.example.com (no path, no trailing slash)" |
| 148 | u, err := url.Parse(o) |
| 149 | if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil || |
| 150 | u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.Opaque != "" || |
| 151 | u.Scheme+"://"+u.Host != o { |
| 152 | return errors.New(msg) |
| 153 | } |
| 154 | return nil |
| 155 | } |
| 156 | |
| 157 | // PublicKeyString is the applicationServerKey the browser subscribes |
| 158 | // with: unpadded base64url of the uncompressed P-256 point. |
| 159 | func (s *Service) PublicKeyString() string { return s.pub } |
| 160 | |