286 lines
6.9 KiB
Go
286 lines
6.9 KiB
Go
package stripe
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// SHA256URLSafe computes SHA-256 and returns URL-safe base64 (no padding).
|
|
// Matches JS: btoa(sha256_raw(input)).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")
|
|
func SHA256URLSafe(input string) string {
|
|
h := sha256.Sum256([]byte(input))
|
|
return base64.RawURLEncoding.EncodeToString(h[:])
|
|
}
|
|
|
|
const urlSalt = "7766e861-8279-424d-87a1-07a6022fd8cd"
|
|
|
|
// SHA256WithSalt hashes input with the Stripe URL salt.
|
|
// Matches JS: sha256WithSalt which does sha256(unescape(encodeURIComponent(e)) + URL_SALT)
|
|
func SHA256WithSalt(input string) string {
|
|
if input == "" {
|
|
return ""
|
|
}
|
|
// JS unescape(encodeURIComponent(s)) converts to UTF-8 bytes, which Go strings already are.
|
|
return SHA256URLSafe(input + urlSalt)
|
|
}
|
|
|
|
// EncodeURIComponent mimics JavaScript's encodeURIComponent.
|
|
func EncodeURIComponent(s string) string {
|
|
result := url.QueryEscape(s)
|
|
result = strings.ReplaceAll(result, "+", "%20")
|
|
// JS encodeURIComponent does NOT encode: - _ . ! ~ * ' ( )
|
|
for _, c := range []string{"!", "'", "(", ")", "*", "~"} {
|
|
result = strings.ReplaceAll(result, url.QueryEscape(c), c)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ---------- URL Hashing ----------
|
|
|
|
const (
|
|
defaultFullHashLimit = 10
|
|
totalPartsLimit = 40
|
|
partialHashLen = 6
|
|
pathPartsLimit = 30
|
|
)
|
|
|
|
func isStripeAuthority(s string) bool {
|
|
if s == "//stripe.com" || s == "//stripe.com." {
|
|
return true
|
|
}
|
|
return strings.HasSuffix(s, ".stripe.com") || strings.HasSuffix(s, ".stripe.com.")
|
|
}
|
|
|
|
func isStripeCheckoutAuthority(s string) bool {
|
|
candidates := []string{
|
|
"//checkout.stripe.com",
|
|
"//qa-checkout.stripe.com",
|
|
"//edge-checkout.stripe.com",
|
|
}
|
|
for _, c := range candidates {
|
|
if s == c {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// removeUserInfo strips user-info from an authority component.
|
|
func removeUserInfo(e string) string {
|
|
if e == "" {
|
|
return e
|
|
}
|
|
t := strings.LastIndex(e, "@")
|
|
if t == -1 {
|
|
return e
|
|
}
|
|
return e[:2] + e[t+1:] // keep "//" prefix
|
|
}
|
|
|
|
// PartitionedUrl parses a URL into RFC-3986 components.
|
|
type PartitionedUrl struct {
|
|
Scheme string
|
|
Authority string
|
|
Path string
|
|
Query string
|
|
Fragment string
|
|
}
|
|
|
|
var urlParseRe = regexp.MustCompile(`^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?`)
|
|
|
|
// NewPartitionedUrl parses a URL string.
|
|
func NewPartitionedUrl(rawURL string) *PartitionedUrl {
|
|
p := &PartitionedUrl{}
|
|
if rawURL == "" {
|
|
return p
|
|
}
|
|
m := urlParseRe.FindStringSubmatch(rawURL)
|
|
if m == nil {
|
|
return p
|
|
}
|
|
p.Scheme = m[1] // e.g. "https:"
|
|
if m[3] != "" {
|
|
p.Authority = removeUserInfo(m[3]) // e.g. "//chatgpt.com"
|
|
}
|
|
p.Path = m[5] // e.g. "/"
|
|
p.Query = m[6] // e.g. "?foo=bar"
|
|
p.Fragment = m[8] // e.g. "#section"
|
|
return p
|
|
}
|
|
|
|
func (p *PartitionedUrl) String() string {
|
|
var parts []string
|
|
for _, s := range []string{p.Scheme, p.Authority, p.Path, p.Query, p.Fragment} {
|
|
if s != "" {
|
|
parts = append(parts, s)
|
|
}
|
|
}
|
|
return strings.Join(parts, "")
|
|
}
|
|
|
|
// SequentialHashWithLimit manages individual segment hashing with full/partial limits.
|
|
type SequentialHashWithLimit struct {
|
|
s string
|
|
cur int
|
|
hashedCount int
|
|
fullHashLimit int
|
|
totalHashLimit int
|
|
}
|
|
|
|
func newSequentialHashWithLimit(s string, fullHashLimit, totalHashLimit int) *SequentialHashWithLimit {
|
|
return &SequentialHashWithLimit{
|
|
s: s,
|
|
cur: 0,
|
|
hashedCount: 0,
|
|
fullHashLimit: fullHashLimit,
|
|
totalHashLimit: totalHashLimit,
|
|
}
|
|
}
|
|
|
|
func (h *SequentialHashWithLimit) shouldHash() bool {
|
|
return h.hashedCount < h.totalHashLimit
|
|
}
|
|
|
|
func (h *SequentialHashWithLimit) isLastHash() bool {
|
|
return h.hashedCount == h.totalHashLimit-1
|
|
}
|
|
|
|
func (h *SequentialHashWithLimit) shouldPartialHash() bool {
|
|
return !h.isLastHash() && h.hashedCount >= h.fullHashLimit
|
|
}
|
|
|
|
func (h *SequentialHashWithLimit) replace(e string) {
|
|
t := e
|
|
n := strings.Index(h.s[h.cur:], e)
|
|
if n == -1 {
|
|
return
|
|
}
|
|
n += h.cur
|
|
|
|
if h.isLastHash() {
|
|
t = h.s[n:]
|
|
}
|
|
|
|
r := SHA256WithSalt(t)
|
|
if h.shouldPartialHash() {
|
|
if len(r) > partialHashLen {
|
|
r = r[:partialHashLen]
|
|
}
|
|
}
|
|
|
|
h.s = h.s[:n] + r + h.s[n+len(t):]
|
|
h.cur = n + len(r)
|
|
h.hashedCount++
|
|
}
|
|
|
|
// SequentialSplitterAndHasher tracks remaining hash budget across URL parts.
|
|
type SequentialSplitterAndHasher struct {
|
|
remainingHashes int
|
|
fullHashLimit int
|
|
}
|
|
|
|
func newSequentialSplitterAndHasher(fullHashLimit int) *SequentialSplitterAndHasher {
|
|
return &SequentialSplitterAndHasher{
|
|
remainingHashes: totalPartsLimit,
|
|
fullHashLimit: fullHashLimit,
|
|
}
|
|
}
|
|
|
|
func (s *SequentialSplitterAndHasher) getFullHashLimit(part string) int {
|
|
if part == "authority" {
|
|
return totalPartsLimit
|
|
}
|
|
return s.fullHashLimit
|
|
}
|
|
|
|
func (s *SequentialSplitterAndHasher) totalHashLimitForPart(part string) int {
|
|
switch part {
|
|
case "authority":
|
|
return totalPartsLimit
|
|
case "path":
|
|
v := s.remainingHashes
|
|
if v > pathPartsLimit {
|
|
v = pathPartsLimit
|
|
}
|
|
if v < 1 {
|
|
v = 1
|
|
}
|
|
return v
|
|
case "query", "fragment":
|
|
if s.remainingHashes < 1 {
|
|
return 1
|
|
}
|
|
return s.remainingHashes
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func (s *SequentialSplitterAndHasher) splitAndHash(input, partType string, delim *regexp.Regexp) string {
|
|
if partType == "authority" && input != "" && isStripeCheckoutAuthority(input) {
|
|
return input
|
|
}
|
|
if input == "" {
|
|
return input
|
|
}
|
|
|
|
h := newSequentialHashWithLimit(input, s.getFullHashLimit(partType), s.totalHashLimitForPart(partType))
|
|
|
|
// Split by delimiter, filter empty strings
|
|
segments := delim.Split(input, -1)
|
|
for _, seg := range segments {
|
|
if seg == "" {
|
|
continue
|
|
}
|
|
if h.shouldHash() {
|
|
h.replace(seg)
|
|
}
|
|
}
|
|
s.remainingHashes -= h.hashedCount
|
|
return h.s
|
|
}
|
|
|
|
// hashURL hashes URL components using SequentialSplitterAndHasher.
|
|
func hashURL(p *PartitionedUrl, fullHashLimit int) *PartitionedUrl {
|
|
r := newSequentialSplitterAndHasher(fullHashLimit)
|
|
|
|
authorityRe := regexp.MustCompile(`[/.:]`)
|
|
otherRe := regexp.MustCompile(`[/#?!&+,=]`)
|
|
|
|
p.Authority = r.splitAndHash(p.Authority, "authority", authorityRe)
|
|
p.Path = r.splitAndHash(p.Path, "path", otherRe)
|
|
p.Query = r.splitAndHash(p.Query, "query", otherRe)
|
|
p.Fragment = r.splitAndHash(p.Fragment, "fragment", otherRe)
|
|
return p
|
|
}
|
|
|
|
// hashURLWithAuthorityCheck applies Stripe-specific authority logic before hashing.
|
|
func hashURLWithAuthorityCheck(p *PartitionedUrl, fullHashLimit int) *PartitionedUrl {
|
|
n := p.Authority
|
|
if n != "" && isStripeCheckoutAuthority(n) {
|
|
return hashURL(p, defaultFullHashLimit)
|
|
}
|
|
if n != "" && isStripeAuthority(n) {
|
|
return p // don't hash stripe.com URLs
|
|
}
|
|
return hashURL(p, fullHashLimit)
|
|
}
|
|
|
|
// HashURL is the public entry point for URL hashing.
|
|
func HashURL(urlStr string) string {
|
|
p := NewPartitionedUrl(urlStr)
|
|
return hashURLWithAuthorityCheck(p, defaultFullHashLimit).String()
|
|
}
|
|
|
|
// GetHashTimestampWithSalt returns "timestamp:hash" string.
|
|
func GetHashTimestampWithSalt(randomValue string) string {
|
|
now := time.Now().UnixMilli()
|
|
hash := SHA256URLSafe(randomValue + fmt.Sprintf("%d", now+1))
|
|
return fmt.Sprintf("%d:%s", now, hash)
|
|
}
|