package chat import ( "errors" "fmt" "net/url" "regexp" "strings" ) const ( // webhookPrefix is how every accepted webhook URL starts, compared // exactly: no other scheme, host spelling, port or user info. webhookPrefix = "https://chat.googleapis.com/v1/spaces/" // webhookHost is the only host a webhook URL may name. webhookHost = "chat.googleapis.com" // MaxWebhookURLLength bounds an accepted webhook URL. MaxWebhookURLLength = 2048 // MinCredentialLength is the shortest key or token accepted, so the // masked URL's token tail never gives away most of it. MinCredentialLength = 16 // maskedTail is how many trailing characters of the token the masked // URL shows. maskedTail = 4 ) // webhookPath is the path of a space's incoming webhook; the group is the // space id. var webhookPath = regexp.MustCompile(`^/v1/spaces/([A-Za-z0-9_-]{1,128})/messages$`) // credentialChars is the alphabet of a decoded key or token: URL-safe // ASCII, so its length in bytes is its length in characters. var credentialChars = regexp.MustCompile(`^[A-Za-z0-9_.~=-]*$`) // Webhook is a validated Google Chat incoming webhook URL. Its String is // the full URL, a secret: it is only ever sealed for storage or sent to // Google Chat, never logged, shown or put in an error. type Webhook struct { raw string space string token string } // ParseWebhook validates raw as // https://chat.googleapis.com/v1/spaces/{space}/messages?key=…&token=…, // whose raw query is exactly key=…&token=…, in that order, each decoding to // at least MinCredentialLength characters of credentialChars. Its error // never quotes raw. func ParseWebhook(raw string) (Webhook, error) { switch { case raw == "": return Webhook{}, errors.New("is required") case len(raw) > MaxWebhookURLLength: return Webhook{}, fmt.Errorf("must be at most %d characters", MaxWebhookURLLength) case strings.ContainsFunc(raw, func(r rune) bool { return r <= ' ' || r == 0x7f }): return Webhook{}, errors.New("must not contain spaces or control characters") case !strings.HasPrefix(raw, webhookPrefix): return Webhook{}, errors.New("must be a Google Chat incoming webhook URL starting with " + webhookPrefix) case strings.Contains(raw, "#"): return Webhook{}, errors.New("must not have a fragment") } u, err := url.Parse(raw) if err != nil { return Webhook{}, errors.New("is not a valid URL") } if u.Scheme != "https" || u.Host != webhookHost || u.User != nil || u.Opaque != "" || u.RawPath != "" { return Webhook{}, errors.New("must be a Google Chat incoming webhook URL starting with " + webhookPrefix) } m := webhookPath.FindStringSubmatch(u.Path) if m == nil { return Webhook{}, errors.New("path must be /v1/spaces/{space}/messages") } // The raw query is exactly key=…&token=…: no empty segment, no second // "?", no other or repeated parameter. if strings.Contains(u.RawQuery, "?") { return Webhook{}, errors.New("query must not contain ?") } segments := strings.Split(u.RawQuery, "&") if len(segments) != 2 { return Webhook{}, errors.New("query must be exactly key=…&token=…") } creds := make([]string, 2) for i, name := range []string{"key", "token"} { escaped, ok := strings.CutPrefix(segments[i], name+"=") if !ok { return Webhook{}, errors.New("query must be exactly key=…&token=…") } v, err := url.QueryUnescape(escaped) if err != nil { return Webhook{}, errors.New("query is not valid") } if !credentialChars.MatchString(v) { return Webhook{}, fmt.Errorf("%s must be letters, digits, -, _, ., ~ or =", name) } if len(v) < MinCredentialLength { return Webhook{}, fmt.Errorf("%s must be at least %d characters", name, MinCredentialLength) } creds[i] = v } return Webhook{raw: raw, space: m[1], token: creds[1]}, nil } // String returns the full webhook URL, a secret. func (w Webhook) String() string { return w.raw } // Masked returns the URL with its key hidden and only the last few // characters of its token, such as // "https://chat.googleapis.com/v1/spaces/AAQA1234/messages?key=…&token=…x3Dk". func (w Webhook) Masked() string { // The token is ASCII of at least MinCredentialLength bytes, so the // tail is maskedTail of its many characters. return webhookPrefix + w.space + "/messages?key=…&token=…" + w.token[len(w.token)-maskedTail:] }