rastrillo / aviso Public

Clone
git clone https://amadan.net/rastrillo/aviso

Plain git — no account needed to clone.

Download

Download this file

1package aviso
2
3import (
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.
15type 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.
36type 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.
46type 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.
57var 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.
62var 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.
66type 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.
87func 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:".
123func 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.
146func 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.
159func (s *Service) PublicKeyString() string { return s.pub }
160