|
|
|
@@ -31,6 +31,16 @@ const (
|
|
|
|
|
squareProductionURL = "https://connect.squareup.com"
|
|
|
|
|
squareAPIVersion = "2026-05-20"
|
|
|
|
|
defaultHTTPTimeout = 30 * time.Second
|
|
|
|
|
|
|
|
|
|
// maxResponseBody caps how many bytes doJSON reads from a response. Square
|
|
|
|
|
// responses are normally a few KB; the cap guards against an OOM from a
|
|
|
|
|
// compromised/proxied Square endpoint streaming unbounded data.
|
|
|
|
|
maxResponseBody = 1 << 20 // 1 MiB
|
|
|
|
|
|
|
|
|
|
// maxErrorBody caps the raw response body embedded in error messages.
|
|
|
|
|
// Handlers log these errors verbatim, so echoing more than a snippet risks
|
|
|
|
|
// leaking PII that Square may have mirrored from the request.
|
|
|
|
|
maxErrorBody = 500
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
@@ -87,15 +97,23 @@ func (c *httpClient) doJSON(ctx context.Context, method, path string, body, targ
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
|
|
|
// Read the response through a limit so a compromised/proxied Square
|
|
|
|
|
// endpoint cannot stream unbounded data into memory (OOM guard). If the
|
|
|
|
|
// limit is exceeded, the body is cut and callers get a truncation error
|
|
|
|
|
// rather than silently parsing a partial response.
|
|
|
|
|
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody+1))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("square: read response: %w", err)
|
|
|
|
|
}
|
|
|
|
|
truncated := len(respBody) > maxResponseBody
|
|
|
|
|
if truncated {
|
|
|
|
|
respBody = respBody[:maxResponseBody]
|
|
|
|
|
}
|
|
|
|
|
if resp.StatusCode >= 300 {
|
|
|
|
|
var errResp struct{ Errors []SquareError `json:"errors"` }
|
|
|
|
|
if json.Unmarshal(respBody, &errResp) == nil && len(errResp.Errors) > 0 {
|
|
|
|
|
se := errResp.Errors[0]
|
|
|
|
|
msg := fmt.Sprintf("square: %s %s: [%s/%s] %s (field: %s)", method, path, se.Category, se.Code, se.Detail, se.Field)
|
|
|
|
|
msg := fmt.Sprintf("square: %s %s: [%s/%s] %s (field: %s)", method, path, se.Category, se.Code, capBody(se.Detail), se.Field)
|
|
|
|
|
return &squareAPIError{
|
|
|
|
|
Code: se.Code,
|
|
|
|
|
Detail: se.Detail,
|
|
|
|
@@ -105,7 +123,24 @@ func (c *httpClient) doJSON(ctx context.Context, method, path string, body, targ
|
|
|
|
|
err: errors.New(msg),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return fmt.Errorf("square: %s %s: HTTP %d: %s", method, path, resp.StatusCode, string(respBody))
|
|
|
|
|
if truncated {
|
|
|
|
|
return fmt.Errorf("square: %s %s: HTTP %d: response body exceeds %d bytes (truncated): %s", method, path, resp.StatusCode, maxResponseBody, capBody(string(respBody)))
|
|
|
|
|
}
|
|
|
|
|
return fmt.Errorf("square: %s %s: HTTP %d: %s", method, path, resp.StatusCode, capBody(string(respBody)))
|
|
|
|
|
}
|
|
|
|
|
if truncated {
|
|
|
|
|
// A truncated 2xx body must never be silently accepted as a partial
|
|
|
|
|
// success. Even when the first maxResponseBody bytes are still valid
|
|
|
|
|
// JSON (e.g. an array cut at an element boundary), Unmarshal succeeds
|
|
|
|
|
// and the caller would otherwise get a partial response with nil error.
|
|
|
|
|
// Real Square responses are a few KB, so this only fires against a
|
|
|
|
|
// compromised/proxied endpoint.
|
|
|
|
|
if target != nil && len(respBody) > 0 {
|
|
|
|
|
if err := json.Unmarshal(respBody, target); err != nil {
|
|
|
|
|
return fmt.Errorf("square: %s %s: response body exceeds %d bytes (truncated), cannot parse: %w", method, path, maxResponseBody, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return fmt.Errorf("square: %s %s: response body exceeds %d bytes (truncated)", method, path, maxResponseBody)
|
|
|
|
|
}
|
|
|
|
|
if target != nil && len(respBody) > 0 {
|
|
|
|
|
if err := json.Unmarshal(respBody, target); err != nil {
|
|
|
|
@@ -115,6 +150,20 @@ func (c *httpClient) doJSON(ctx context.Context, method, path string, body, targ
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// capBody returns s truncated to maxErrorBody bytes with a truncation marker.
|
|
|
|
|
// Error messages are logged verbatim by handlers, so embedding more than a
|
|
|
|
|
// snippet of a (possibly echoed) response body risks leaking PII.
|
|
|
|
|
func capBody(s string) string {
|
|
|
|
|
if len(s) <= maxErrorBody {
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
// Truncate on a rune boundary, not a raw byte slice: s[:maxErrorBody] can
|
|
|
|
|
// split a multi-byte UTF-8 sequence, producing invalid UTF-8 in an error
|
|
|
|
|
// message logged verbatim (mangles logs feeding UTF-8-sensitive tooling).
|
|
|
|
|
// ToValidUTF8 drops the partial rune at the cut.
|
|
|
|
|
return strings.ToValidUTF8(s[:maxErrorBody], "") + "... (truncated)"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Square JSON types — exact wire-format match with Square's REST API.
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
@@ -349,6 +398,18 @@ func validSquareID(id string) bool {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// validCardID reports whether a card ID is safe to embed in a Square REST URL
|
|
|
|
|
// path segment. Card IDs (ccof:xxx) carry a "ccof:" colon prefix that plain
|
|
|
|
|
// Square IDs do not, so the prefix is stripped before the standard
|
|
|
|
|
// validSquareID charset check (which rejects ":"); everything after the
|
|
|
|
|
// prefix must still pass the same alphanumeric/_/- rule.
|
|
|
|
|
func validCardID(id string) bool {
|
|
|
|
|
if strings.HasPrefix(id, "ccof:") {
|
|
|
|
|
return validSquareID(id[len("ccof:"):])
|
|
|
|
|
}
|
|
|
|
|
return validSquareID(id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// isTokenLike returns true for Square source_id tokens: cnon:xxx nonces and
|
|
|
|
|
// ccof:xxx card IDs. Raw PANs (all digits) are NOT token-like and are rejected.
|
|
|
|
|
// This is the single source of truth for token validation, shared by the real
|
|
|
|
@@ -369,6 +430,13 @@ func tokenPrefix(s string) string {
|
|
|
|
|
return fmt.Sprintf("%s (len %d)", s, len(s))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TokenPrefix is the exported form of tokenPrefix, for packages outside
|
|
|
|
|
// internal/square (e.g. handlers) that log ccof:/cnon: card tokens. The full
|
|
|
|
|
// token must never reach logs.
|
|
|
|
|
func TokenPrefix(s string) string {
|
|
|
|
|
return tokenPrefix(s)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func createPaymentHTTP(ctx context.Context, req CreatePaymentReq) (*PaymentResult, error) {
|
|
|
|
|
return createPaymentHTTPWithClient(ctx, req, newHTTPClient())
|
|
|
|
|
}
|
|
|
|
@@ -662,8 +730,9 @@ func createCardOnFileHTTPWithClient(ctx context.Context, userID, cardToken, cust
|
|
|
|
|
// Deterministic idempotency key derived from user + card (not time-based)
|
|
|
|
|
// so that retries with the same details don't create duplicate cards.
|
|
|
|
|
// SHA-256 hash prevents recovering the card token from the key itself.
|
|
|
|
|
// Truncated to ≤45 chars — Square's documented idempotency-key limit for
|
|
|
|
|
// /v2/cards (a full 64-hex hash would be rejected with a 400).
|
|
|
|
|
// Truncated to ≤45 chars — Square's idempotency-key limit is 45 chars for
|
|
|
|
|
// /v2/cards, /v2/payments, and /v2/refunds (64 only for
|
|
|
|
|
// /v2/terminals/checkouts).
|
|
|
|
|
ikHash := sha256.Sum256([]byte(userID + "|" + cardToken))
|
|
|
|
|
body := sqCreateCardRequest{
|
|
|
|
|
IdempotencyKey: "card-" + fmt.Sprintf("%x", ikHash)[:38],
|
|
|
|
@@ -729,6 +798,11 @@ func getCardsOnFileHTTPWithClient(ctx context.Context, userID string, hc *httpCl
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func deleteCardOnFileHTTP(ctx context.Context, cardID string) error {
|
|
|
|
|
if !validCardID(cardID) {
|
|
|
|
|
// cardID is a DB-stored ccof: token — never echo the full value in an
|
|
|
|
|
// error (handlers log it verbatim).
|
|
|
|
|
return fmt.Errorf("square: invalid card id %s", tokenPrefix(cardID))
|
|
|
|
|
}
|
|
|
|
|
hc := newHTTPClient()
|
|
|
|
|
var resp sqDisableCardResponse
|
|
|
|
|
if err := hc.doJSON(ctx, http.MethodPost, "/v2/cards/"+cardID+"/disable", nil, &resp); err != nil {
|
|
|
|
@@ -737,6 +811,25 @@ func deleteCardOnFileHTTP(ctx context.Context, cardID string) error {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func deleteCustomerHTTP(ctx context.Context, customerID string) error {
|
|
|
|
|
return deleteCustomerHTTPWithClient(ctx, customerID, newHTTPClient())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func deleteCustomerHTTPWithClient(ctx context.Context, customerID string, hc *httpClient) error {
|
|
|
|
|
if !validSquareID(customerID) {
|
|
|
|
|
return fmt.Errorf("square: invalid customer id %q", customerID)
|
|
|
|
|
}
|
|
|
|
|
if err := hc.doJSON(ctx, http.MethodDelete, "/v2/customers/"+customerID, nil, nil); err != nil {
|
|
|
|
|
// Square returns 404 / NOT_FOUND when the customer is already deleted —
|
|
|
|
|
// that is a no-op, not a failure (idempotent re-deletion on GDPR erasure).
|
|
|
|
|
if IsNotFound(err) {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func createCustomerHTTP(ctx context.Context, name, email string) (*CustomerResult, error) {
|
|
|
|
|
return createCustomerHTTPWithClient(ctx, name, email, newHTTPClient())
|
|
|
|
|
}
|
|
|
|
@@ -745,7 +838,8 @@ func createCustomerHTTPWithClient(ctx context.Context, name, email string, hc *h
|
|
|
|
|
// Deterministic idempotency key derived from the email (not time-based)
|
|
|
|
|
// so retries with the same email don't create duplicate customers. SHA-256
|
|
|
|
|
// prevents recovering the email from the key. Truncated to ≤45 chars —
|
|
|
|
|
// Square's documented idempotency-key limit.
|
|
|
|
|
// Square's idempotency-key limit is 45 chars for /v2/cards, /v2/payments,
|
|
|
|
|
// and /v2/refunds (64 only for /v2/terminals/checkouts).
|
|
|
|
|
ikHash := sha256.Sum256([]byte(email))
|
|
|
|
|
body := sqCreateCustomerRequest{
|
|
|
|
|
IdempotencyKey: "customer-" + fmt.Sprintf("%x", ikHash)[:35],
|
|
|
|
@@ -816,12 +910,16 @@ func paymentFromSquare(sq *sqPayment) *PaymentResult {
|
|
|
|
|
r.AVSStatus = cd.AVSStatus
|
|
|
|
|
r.CardBrand = cd.Card.CardBrand
|
|
|
|
|
r.CardLast4 = cd.Card.Last4
|
|
|
|
|
// exp_month/exp_year ride on the card object — pointer set when present.
|
|
|
|
|
// exp_month/exp_year/fingerprint ride on the card object. When the
|
|
|
|
|
// card object is empty (ID == "") they are meaningless, so leave ALL of
|
|
|
|
|
// them nil — nil then consistently means "no card details present"
|
|
|
|
|
// (previously ExpMonth/ExpYear became 0/0 pointers while Fingerprint
|
|
|
|
|
// stayed nil, which was inconsistent).
|
|
|
|
|
if cd.Card.ID != "" {
|
|
|
|
|
expMonth := cd.Card.ExpMonth
|
|
|
|
|
expYear := cd.Card.ExpYear
|
|
|
|
|
r.ExpMonth = &expMonth
|
|
|
|
|
r.ExpYear = &expYear
|
|
|
|
|
if cd.Card.ID != "" {
|
|
|
|
|
r.CardFingerprint = cd.Card.Fingerprint
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|