diff --git a/admin/internal/server/task_claims.go b/admin/internal/server/task_claims.go index cf60341..70c360b 100644 --- a/admin/internal/server/task_claims.go +++ b/admin/internal/server/task_claims.go @@ -14,7 +14,10 @@ import ( "github.com/gin-gonic/gin" ) -const maxClaimJSONBytes = 4096 +const ( + maxClaimJSONBytes = 4096 + maxClaimResponseJSONBytes = 32 * 1024 +) func claimNext(options Options) gin.HandlerFunc { return func(context *gin.Context) { @@ -35,7 +38,16 @@ func claimNext(options Options) gin.HandlerFunc { context.Status(http.StatusNoContent) return } - context.JSON(http.StatusOK, response) + if !taskclaim.ValidClaimResponse(response) { + context.Status(http.StatusServiceUnavailable) + return + } + encoded, err := json.Marshal(response) + if err != nil || len(encoded) > maxClaimResponseJSONBytes { + context.Status(http.StatusServiceUnavailable) + return + } + context.Data(http.StatusOK, "application/json; charset=utf-8", encoded) } } diff --git a/admin/internal/server/task_claims_test.go b/admin/internal/server/task_claims_test.go index 0e1897c..0b25a54 100644 --- a/admin/internal/server/task_claims_test.go +++ b/admin/internal/server/task_claims_test.go @@ -3,6 +3,7 @@ package server_test import ( "bytes" "context" + "encoding/json" "errors" "io" "net/http" @@ -98,6 +99,52 @@ func TestClaimNextStrictJSONSuccessEmptyAndErrors(t *testing.T) { } } +func TestClaimResponseWorstLegalFieldsStayBelowCapAndInvalidServiceOutputFailsClosed(t *testing.T) { + goodsID := strings.Repeat("1", 32) + worst := taskclaim.ClaimResponse{ + Task: taskclaim.ClaimedTask{ + ID: claimTaskID, Version: 3, Title: strings.Repeat("<", 120), + ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID, + GoodsID: goodsID, SKUColor: strings.Repeat("<", 80), SKUSize: strings.Repeat("<", 80), + Quantity: 9_223_372_036_854_775_807, MaxTotalPrice: strings.Repeat("9", 29) + ".00", + }, + Authorization: taskclaim.ClaimedAuthorization{ID: "70000000-0000-4000-8000-000000000001", TaskVersion: 2, ExpiresAt: "9999-12-31T23:59:59.999999999Z"}, + Attempt: taskclaim.ClaimedAttempt{ID: claimAttemptID, ClaimToken: strings.Repeat("a", 64), ClaimGeneration: 9_223_372_036_854_775_807, LeaseExpiresAt: "9999-12-31T23:59:59.999999999Z"}, + } + authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}} + service := &fakeTaskClaimService{claimResponse: worst, claimFound: true} + router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service) + request := `{"session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `"}` + response := serveClaimJSON(router, "/api/v1/tasks/claim-next", request, "application/json") + if response.Code != http.StatusOK || !json.Valid(response.Body.Bytes()) || response.Body.Len() >= 32*1024 { + t.Fatalf("worst legal response = status %d, bytes %d, valid JSON %v", response.Code, response.Body.Len(), json.Valid(response.Body.Bytes())) + } + + mutations := map[string]func(*taskclaim.ClaimResponse){ + "invalid utf8 title": func(response *taskclaim.ClaimResponse) { response.Task.Title = string([]byte{0xff}) }, + "overlong title": func(response *taskclaim.ClaimResponse) { response.Task.Title += "<" }, + "overlong goods id": func(response *taskclaim.ClaimResponse) { + response.Task.GoodsID += "1" + response.Task.ProductURL += "1" + }, + "overlong color": func(response *taskclaim.ClaimResponse) { response.Task.SKUColor += "<" }, + "overlong size": func(response *taskclaim.ClaimResponse) { response.Task.SKUSize += "<" }, + "overlong money": func(response *taskclaim.ClaimResponse) { response.Task.MaxTotalPrice = strings.Repeat("9", 30) + ".00" }, + } + for name, mutate := range mutations { + t.Run(name, func(t *testing.T) { + invalid := worst + mutate(&invalid) + service := &fakeTaskClaimService{claimResponse: invalid, claimFound: true} + router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service) + response := serveClaimJSON(router, "/api/v1/tasks/claim-next", request, "application/json") + if response.Code != http.StatusServiceUnavailable || response.Body.Len() != 0 { + t.Fatalf("invalid service response = %d %q", response.Code, response.Body.String()) + } + }) + } +} + func TestRenewStrictBindingResponseAndFixedErrors(t *testing.T) { authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}} service := &fakeTaskClaimService{renewResponse: taskclaim.RenewResponse{TaskID: claimTaskID, AttemptID: claimAttemptID, ClaimGeneration: 1, LeaseExpiresAt: "2026-08-04T01:04:00Z"}} diff --git a/admin/internal/taskclaim/store.go b/admin/internal/taskclaim/store.go index 8aefe2f..30d0faa 100644 --- a/admin/internal/taskclaim/store.go +++ b/admin/internal/taskclaim/store.go @@ -10,12 +10,12 @@ import ( "fmt" "io" "math" - "math/big" "strings" "sync" "time" "cmbuyer/admin/internal/deviceauth" + taskmodel "cmbuyer/admin/internal/tasks" ) const writeTimeout = 2 * time.Second @@ -80,7 +80,7 @@ func (store *Store) validateStoredClaims(ctx context.Context) error { rows, err := store.database.QueryContext(ctx, `SELECT claims.claimed_by_device_id, claims.task_id, claims.authorization_id, claims.attempt_id, claims.claim_generation, claims.claim_nonce, typeof(claims.claim_nonce), length(claims.claim_nonce), claims.claim_token_sha256, typeof(claims.claim_token_sha256), length(claims.claim_token_sha256), - claims.authorization_task_version, claims.goods_id, claims.sku_color, claims.sku_size, + claims.task_title, claims.authorization_task_version, claims.goods_id, claims.sku_color, claims.sku_size, claims.quantity, claims.total_price_cap, claims.authorization_expires_at, claims.closed_at, attempts.claim_generation, attempts.status, authorizations.status, tasks.status FROM purchase_attempt_claims AS claims @@ -96,21 +96,21 @@ func (store *Store) validateStoredClaims(ctx context.Context) error { var deviceID, taskID, authorizationID, attemptID string var generation, authorizationTaskVersion, quantity int var nonce, storedHash []byte - var nonceType, hashType, goodsID, color, size, price, expires string + var nonceType, hashType, title, goodsID, color, size, price, expires string var nonceLength, hashLength int var closed, attemptStatus, authorizationStatus, taskStatus sql.NullString var attemptGeneration sql.NullInt64 if err := rows.Scan(&deviceID, &taskID, &authorizationID, &attemptID, &generation, &nonce, &nonceType, &nonceLength, &storedHash, &hashType, &hashLength, - &authorizationTaskVersion, &goodsID, &color, &size, &quantity, &price, &expires, &closed, + &title, &authorizationTaskVersion, &goodsID, &color, &size, &quantity, &price, &expires, &closed, &attemptGeneration, &attemptStatus, &authorizationStatus, &taskStatus); err != nil { return errors.New("validate stored task claims") } if !deviceauth.ValidDeviceID(deviceID) || !validUUID(taskID) || !validUUID(authorizationID) || !validUUID(attemptID) || generation <= 0 || nonceType != "blob" || nonceLength != sha256.Size || len(nonce) != sha256.Size || hashType != "blob" || hashLength != sha256.Size || len(storedHash) != sha256.Size || - authorizationTaskVersion <= 0 || !digitsOnly(goodsID) || color == "" || size == "" || quantity <= 0 || - !canonicalMoney(price) || !validCanonicalTime(expires) || (closed.Valid && !validCanonicalTime(closed.String)) || + authorizationTaskVersion <= 0 || !taskmodel.ValidTaskWireFields(title, goodsID, color, size, price) || quantity <= 0 || + !validCanonicalTime(expires) || (closed.Valid && !validCanonicalTime(closed.String)) || !attemptGeneration.Valid || attemptGeneration.Int64 != int64(generation) || !validAttemptStatus(attemptStatus) || !validAuthorizationStatus(authorizationStatus) || !validTaskStatus(taskStatus) { return errors.New("stored task claim metadata is invalid") @@ -577,9 +577,9 @@ func (store *Store) scanClaim(row rowScanner) (claimRecord, bool, error) { if !validUUID(record.AttemptID) || !validUUID(record.TaskID) || !validUUID(record.AuthorizationID) || !deviceauth.ValidDeviceID(record.DeviceID) || !validUUID(record.SessionID) || record.Generation <= 0 || record.CurrentAttemptGeneration != record.Generation || - record.TaskVersion <= 0 || record.AuthorizationTaskVersion <= 0 || strings.TrimSpace(record.TaskTitle) == "" || - !digitsOnly(record.GoodsID) || record.SKUColor == "" || record.SKUSize == "" || record.Quantity <= 0 || - !canonicalMoney(record.TotalPriceCap) || len(record.Nonce) != sha256.Size || len(record.TokenHash) != sha256.Size { + record.TaskVersion <= 0 || record.AuthorizationTaskVersion <= 0 || + !taskmodel.ValidTaskWireFields(record.TaskTitle, record.GoodsID, record.SKUColor, record.SKUSize, record.TotalPriceCap) || + record.Quantity <= 0 || len(record.Nonce) != sha256.Size || len(record.TokenHash) != sha256.Size { return claimRecord{}, false, errors.New("stored task claim metadata is invalid") } record.LeaseExpiresAt, err = parseCanonicalTime(record.LeaseExpiresText) @@ -602,7 +602,11 @@ func (store *Store) scanClaim(row rowScanner) (claimRecord, bool, error) { } func (record claimRecord) authorizationConsistent() bool { - return record.AuthorizationTaskVersion == record.CurrentAuthorizationTaskVersion && + return taskmodel.ValidAuthorizationFields(record.CurrentGoodsID, record.CurrentSKUColor, + record.CurrentSKUSize, record.CurrentTotalPriceCap) && + taskmodel.ValidTaskWireFields(record.CurrentTaskTitle, record.CurrentTaskGoodsID, + record.CurrentTaskSKUColor, record.CurrentTaskSKUSize, record.CurrentTaskMaxTotalPrice) && + record.AuthorizationTaskVersion == record.CurrentAuthorizationTaskVersion && record.GoodsID == record.CurrentGoodsID && record.SKUColor == record.CurrentSKUColor && record.SKUSize == record.CurrentSKUSize && record.Quantity == record.CurrentQuantity && record.TotalPriceCap == record.CurrentTotalPriceCap && @@ -623,8 +627,12 @@ func (record claimRecord) recoverableBusinessState() bool { } func (store *Store) responseFor(record claimRecord, responseLease string) (ClaimResponse, error) { - if !validCanonicalTime(responseLease) { - return ClaimResponse{}, errors.New("stored claim response lease is invalid") + // Exact idempotent replay is allowed to ignore later source-row drift, but the + // immutable response snapshot itself must still satisfy the current wire bounds. + if !validCanonicalTime(responseLease) || + !taskmodel.ValidTaskWireFields(record.TaskTitle, record.GoodsID, record.SKUColor, record.SKUSize, record.TotalPriceCap) || + record.Quantity <= 0 { + return ClaimResponse{}, errors.New("stored claim response snapshot is invalid") } token := deriveToken(store.secret, record.DeviceID, record.TaskID, record.AuthorizationID, record.AttemptID, record.Generation, record.Nonce) return ClaimResponse{ @@ -690,8 +698,8 @@ func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (can func validCandidate(item candidate) bool { return validUUID(item.AuthorizationID) && validUUID(item.TaskID) && item.TaskVersion > 0 && item.TaskVersion < math.MaxInt && - strings.TrimSpace(item.Title) != "" && digitsOnly(item.GoodsID) && item.SKUColor != "" && item.SKUSize != "" && - item.Quantity > 0 && canonicalMoney(item.TotalPriceCap) + taskmodel.ValidTaskWireFields(item.Title, item.GoodsID, item.SKUColor, item.SKUSize, item.TotalPriceCap) && + item.Quantity > 0 } func validAttemptStatus(value sql.NullString) bool { @@ -798,33 +806,6 @@ func validUUID(value string) bool { return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b') } -func digitsOnly(value string) bool { - if value == "" { - return false - } - for _, character := range value { - if character < '0' || character > '9' { - return false - } - } - return true -} - -func canonicalMoney(value string) bool { - parts := strings.Split(value, ".") - if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) != 2 || (len(parts[0]) > 1 && parts[0][0] == '0') { - return false - } - for _, part := range parts { - if !digitsOnly(part) { - return false - } - } - cents := new(big.Int) - _, ok := cents.SetString(parts[0]+parts[1], 10) - return ok && cents.Sign() > 0 -} - func productURL(goodsID string) string { return "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID } diff --git a/admin/internal/taskclaim/store_test.go b/admin/internal/taskclaim/store_test.go index fba5aad..4986e87 100644 --- a/admin/internal/taskclaim/store_test.go +++ b/admin/internal/taskclaim/store_test.go @@ -193,6 +193,88 @@ func TestClaimRollsBackEveryBusinessMutationOnLateFailure(t *testing.T) { } } +func TestClaimRejectsOutOfBoundsCandidatesWithoutBusinessMutation(t *testing.T) { + mutations := map[string]func(*testing.T, *sql.DB){ + "invalid utf8 title": func(t *testing.T, database *sql.DB) { + execClaimSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, string([]byte{0xff}), testTaskA) + }, + "overlong title": func(t *testing.T, database *sql.DB) { + execClaimSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", 121), testTaskA) + }, + "overlong goods id": func(t *testing.T, database *sql.DB) { + value := strings.Repeat("1", 33) + execClaimSQL(t, database, `UPDATE tasks SET goods_id=? WHERE id=?`, value, testTaskA) + execClaimSQL(t, database, `UPDATE order_authorizations SET goods_id=? WHERE id=?`, value, testAuthA) + }, + "overlong color": func(t *testing.T, database *sql.DB) { + value := strings.Repeat("色", 81) + execClaimSQL(t, database, `UPDATE tasks SET sku_color=? WHERE id=?`, value, testTaskA) + execClaimSQL(t, database, `UPDATE order_authorizations SET sku_color=? WHERE id=?`, value, testAuthA) + }, + "overlong size": func(t *testing.T, database *sql.DB) { + value := strings.Repeat("码", 81) + execClaimSQL(t, database, `UPDATE tasks SET sku_size=? WHERE id=?`, value, testTaskA) + execClaimSQL(t, database, `UPDATE order_authorizations SET sku_size=? WHERE id=?`, value, testAuthA) + }, + "overlong money": func(t *testing.T, database *sql.DB) { + value := strings.Repeat("1", 30) + ".00" + execClaimSQL(t, database, `UPDATE tasks SET max_total_price=? WHERE id=?`, value, testTaskA) + execClaimSQL(t, database, `UPDATE order_authorizations SET total_price_cap=? WHERE id=?`, value, testAuthA) + }, + } + for name, mutate := range mutations { + t.Run(name, func(t *testing.T) { + database := openClaimTestDatabase(t) + insertDevice(t, database, testDeviceA, []byte("device-a")) + insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(time.Minute), true) + mutate(t, database) + store := mustStore(t, database, bytes.Repeat([]byte{0x32}, 32), 30*time.Second) + store.now = func() time.Time { return testNow } + if _, found, err := store.ClaimNext(context.Background(), testDeviceA, ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA}); err == nil || found { + t.Fatalf("ClaimNext = found %v, err %v; want closed failure", found, err) + } + assertClaimState(t, database, 0, "PENDING", "ACTIVE") + }) + } +} + +func TestClaimReplayRevalidatesImmutableResponseSnapshot(t *testing.T) { + mutations := map[string]func(*testing.T, *sql.DB){ + "title": func(t *testing.T, database *sql.DB) { + execClaimSQL(t, database, `UPDATE purchase_attempt_claims SET task_title=? WHERE task_id=?`, strings.Repeat("😀", 121), testTaskA) + }, + "goods id": func(t *testing.T, database *sql.DB) { + execClaimSQL(t, database, `UPDATE purchase_attempt_claims SET goods_id=? WHERE task_id=?`, strings.Repeat("1", 33), testTaskA) + }, + "color": func(t *testing.T, database *sql.DB) { + execClaimSQL(t, database, `UPDATE purchase_attempt_claims SET sku_color=? WHERE task_id=?`, strings.Repeat("色", 81), testTaskA) + }, + "size": func(t *testing.T, database *sql.DB) { + execClaimSQL(t, database, `UPDATE purchase_attempt_claims SET sku_size=? WHERE task_id=?`, strings.Repeat("码", 81), testTaskA) + }, + "money": func(t *testing.T, database *sql.DB) { + execClaimSQL(t, database, `UPDATE purchase_attempt_claims SET total_price_cap=? WHERE task_id=?`, strings.Repeat("1", 30)+".00", testTaskA) + }, + } + for name, mutate := range mutations { + t.Run(name, func(t *testing.T) { + database := openClaimTestDatabase(t) + insertDevice(t, database, testDeviceA, []byte("device-a")) + insertCandidate(t, database, testTaskA, testAuthA, testNow, testNow.Add(time.Minute), true) + store := mustStore(t, database, bytes.Repeat([]byte{0x34}, 32), 30*time.Second) + store.now = func() time.Time { return testNow } + command := ClaimCommand{SessionID: testSessionA, ClaimRequestID: testClaimRequestA} + if _, found, err := store.ClaimNext(context.Background(), testDeviceA, command); err != nil || !found { + t.Fatalf("initial ClaimNext = found %v, err %v", found, err) + } + mutate(t, database) + if _, found, err := store.ClaimNext(context.Background(), testDeviceA, command); err == nil || found { + t.Fatalf("replay = found %v, err %v; want invalid snapshot", found, err) + } + }) + } +} + func TestClaimEligibilityStableOrderAndConcurrentUniqueness(t *testing.T) { database := openClaimTestDatabase(t) insertDevice(t, database, testDeviceA, []byte("device-a")) @@ -849,6 +931,13 @@ func insertCandidate(t *testing.T, database *sql.DB, taskID, authorizationID str } } +func execClaimSQL(t *testing.T, database *sql.DB, statement string, arguments ...any) { + t.Helper() + if _, err := database.Exec(statement, arguments...); err != nil { + t.Fatalf("execute claim test SQL: %v", err) + } +} + func assertClaimState(t *testing.T, database *sql.DB, wantClaims int, wantTaskStatus, wantAuthorizationStatus string) { t.Helper() var count int diff --git a/admin/internal/taskclaim/types.go b/admin/internal/taskclaim/types.go index 0a2163e..e912a30 100644 --- a/admin/internal/taskclaim/types.go +++ b/admin/internal/taskclaim/types.go @@ -5,6 +5,9 @@ package taskclaim import ( "context" "errors" + "math" + + taskmodel "cmbuyer/admin/internal/tasks" ) var ( @@ -61,6 +64,29 @@ type ClaimResponse struct { Attempt ClaimedAttempt `json:"attempt"` } +// ValidClaimResponse closes the service-to-HTTP boundary as well as the SQLite +// boundary. A fake or future Service implementation cannot bypass the same field +// limits enforced while creating and claiming the task. +func ValidClaimResponse(response ClaimResponse) bool { + authorizationExpires, authorizationErr := parseCanonicalTime(response.Authorization.ExpiresAt) + leaseExpires, leaseErr := parseCanonicalTime(response.Attempt.LeaseExpiresAt) + return validUUID(response.Task.ID) && response.Task.Version > 0 && + response.Authorization.TaskVersion > 0 && response.Authorization.TaskVersion < math.MaxInt && + response.Task.Version == response.Authorization.TaskVersion+1 && + taskmodel.ValidTaskWireFields(response.Task.Title, response.Task.GoodsID, + response.Task.SKUColor, response.Task.SKUSize, response.Task.MaxTotalPrice) && + response.Task.ProductURL == productURL(response.Task.GoodsID) && response.Task.Quantity > 0 && + validUUID(response.Authorization.ID) && authorizationErr == nil && + validUUID(response.Attempt.ID) && response.Attempt.ClaimGeneration > 0 && + len(response.Attempt.ClaimToken) == 64 && tokenTextValid(response.Attempt.ClaimToken) && + leaseErr == nil && !leaseExpires.After(authorizationExpires) +} + +func tokenTextValid(value string) bool { + _, ok := decodeToken(value) + return ok +} + type RenewResponse struct { TaskID string `json:"task_id"` AttemptID string `json:"attempt_id"` diff --git a/admin/internal/tasks/authorization.go b/admin/internal/tasks/authorization.go index c345019..29f64a7 100644 --- a/admin/internal/tasks/authorization.go +++ b/admin/internal/tasks/authorization.go @@ -50,7 +50,7 @@ type TaskRow struct { } func normalizeCents(value string) (string, *big.Int, bool) { - if value == "" || strings.TrimSpace(value) != value { + if value == "" || len(value) > MaxMoneyASCIICharacters || strings.TrimSpace(value) != value { return "", nil, false } parts := strings.Split(value, ".") @@ -71,6 +71,11 @@ func normalizeCents(value string) (string, *big.Int, bool) { return value, cents, true } +func ValidCanonicalMoney(value string) bool { + canonical, _, ok := normalizeCents(value) + return ok && canonical == value +} + func startItems(command StartCommand) ([]StartItem, error) { if !validUUID(command.StartKey) || len(command.Tasks) == 0 || len(command.Tasks) > maxStartItems { return nil, ErrInvalidStart diff --git a/admin/internal/tasks/start_purchases_store_test.go b/admin/internal/tasks/start_purchases_store_test.go index 173ec04..58d1df3 100644 --- a/admin/internal/tasks/start_purchases_store_test.go +++ b/admin/internal/tasks/start_purchases_store_test.go @@ -8,6 +8,7 @@ import ( "math" "reflect" "sort" + "strings" "sync" "testing" "time" @@ -164,12 +165,27 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T "nondigit goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) { execTestSQL(t, store.database, `UPDATE tasks SET goods_id='937x' WHERE id=?`, id) }, + "overlong goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) { + execTestSQL(t, store.database, `UPDATE tasks SET goods_id=? WHERE id=?`, strings.Repeat("1", MaxGoodsIDCharacters+1), id) + }, + "invalid utf8 title": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) { + execTestSQL(t, store.database, `UPDATE tasks SET title=? WHERE id=?`, string([]byte{0xff}), id) + }, + "overlong title": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) { + execTestSQL(t, store.database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", MaxTitleCodePoints+1), id) + }, "empty color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) { execTestSQL(t, store.database, `UPDATE tasks SET sku_color='' WHERE id=?`, id) }, + "overlong color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) { + execTestSQL(t, store.database, `UPDATE tasks SET sku_color=? WHERE id=?`, strings.Repeat("色", MaxSKUTextCodePoints+1), id) + }, "empty size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) { execTestSQL(t, store.database, `UPDATE tasks SET sku_size='' WHERE id=?`, id) }, + "overlong size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) { + execTestSQL(t, store.database, `UPDATE tasks SET sku_size=? WHERE id=?`, strings.Repeat("码", MaxSKUTextCodePoints+1), id) + }, "quantity over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) { store.policy.MaxQuantity = 1 }, @@ -179,6 +195,9 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T "noncanonical leading zero": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) { execTestSQL(t, store.database, `UPDATE tasks SET max_total_price='012.80' WHERE id=?`, id) }, + "overlong canonical price": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) { + execTestSQL(t, store.database, `UPDATE tasks SET max_total_price=? WHERE id=?`, strings.Repeat("1", MaxMoneyASCIICharacters-2)+".00", id) + }, "price over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) { store.policy.MaxTotalPrice = "12.79" }, @@ -199,6 +218,43 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T } } +func TestStartPurchasesReplayRejectsMalformedAuthorizationOrTaskSnapshot(t *testing.T) { + mutations := map[string]func(*testing.T, *sql.DB, string){ + "authorization goods id": func(t *testing.T, database *sql.DB, id string) { + execTestSQL(t, database, `UPDATE order_authorizations SET goods_id=? WHERE task_id=?`, strings.Repeat("1", MaxGoodsIDCharacters+1), id) + }, + "authorization color": func(t *testing.T, database *sql.DB, id string) { + execTestSQL(t, database, `UPDATE order_authorizations SET sku_color=? WHERE task_id=?`, strings.Repeat("色", MaxSKUTextCodePoints+1), id) + }, + "authorization size": func(t *testing.T, database *sql.DB, id string) { + execTestSQL(t, database, `UPDATE order_authorizations SET sku_size=? WHERE task_id=?`, strings.Repeat("码", MaxSKUTextCodePoints+1), id) + }, + "authorization money": func(t *testing.T, database *sql.DB, id string) { + execTestSQL(t, database, `UPDATE order_authorizations SET total_price_cap=? WHERE task_id=?`, strings.Repeat("1", MaxMoneyASCIICharacters-2)+".00", id) + }, + "task title": func(t *testing.T, database *sql.DB, id string) { + execTestSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", MaxTitleCodePoints+1), id) + }, + } + for name, mutate := range mutations { + t.Run(name, func(t *testing.T) { + database := migratedDatabase(t) + store := configuredStartStore(t, database) + id := startTestUUID(1) + createStartDraft(t, store, id) + command := StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: id, ExpectedTaskVersion: 1}}} + if _, err := store.StartPurchases(context.Background(), command, "admin"); err != nil { + t.Fatal(err) + } + mutate(t, database, id) + if _, err := store.StartPurchases(context.Background(), command, "admin"); !errors.Is(err, ErrStartConflict) { + t.Fatalf("replay error = %v, want ErrStartConflict", err) + } + assertAuthorizationCount(t, database, 1) + }) + } +} + func TestStartPurchasesRollsBackWholeBatchForLateConflictAndSQLFailure(t *testing.T) { for _, test := range []struct { name string diff --git a/admin/internal/tasks/start_store.go b/admin/internal/tasks/start_store.go index 2037d17..19ffb0d 100644 --- a/admin/internal/tasks/start_store.go +++ b/admin/internal/tasks/start_store.go @@ -142,7 +142,9 @@ func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartComma } return StartResult{}, err } - if status != "DRAFT" || version != item.ExpectedTaskVersion || !goodsIDValid(goods) || color == "" || size == "" || quantity < 1 || quantity > store.policy.MaxQuantity { + if status != "DRAFT" || version != item.ExpectedTaskVersion || + !ValidTaskWireFields(title, goods, color, size, price) || + quantity < 1 || quantity > store.policy.MaxQuantity { return StartResult{}, ErrStartConflict } canonical, cents, ok := normalizeCents(price) @@ -179,20 +181,15 @@ func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartComma return result, nil } -func goodsIDValid(value string) bool { - if value == "" { - return false - } - for _, ch := range value { - if ch < '0' || ch > '9' { - return false - } - } - return true -} - func replayStart(ctx context.Context, tx *sql.Tx, startKey string, items []StartItem) (StartResult, bool, error) { - rows, err := tx.QueryContext(ctx, "SELECT id,task_id,task_version,expires_at FROM order_authorizations WHERE start_key=? ORDER BY task_id", startKey) + rows, err := tx.QueryContext(ctx, `SELECT authorizations.id,authorizations.task_id, + authorizations.task_version,authorizations.expires_at,authorizations.goods_id, + authorizations.sku_color,authorizations.sku_size,authorizations.quantity, + authorizations.total_price_cap,tasks.title,tasks.goods_id,tasks.sku_color, + tasks.sku_size,tasks.quantity,tasks.max_total_price + FROM order_authorizations AS authorizations + JOIN tasks ON tasks.id = authorizations.task_id + WHERE authorizations.start_key=? ORDER BY authorizations.task_id`, startKey) if err != nil { return StartResult{}, false, err } @@ -201,9 +198,22 @@ func replayStart(ctx context.Context, tx *sql.Tx, startKey string, items []Start for rows.Next() { var item AuthorizedTask var expires string - if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &expires); err != nil { + var authorizationGoodsID, authorizationColor, authorizationSize, authorizationPrice string + var taskTitle, taskGoodsID, taskColor, taskSize, taskPrice string + var authorizationQuantity, taskQuantity int + if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &expires, + &authorizationGoodsID, &authorizationColor, &authorizationSize, &authorizationQuantity, + &authorizationPrice, &taskTitle, &taskGoodsID, &taskColor, &taskSize, &taskQuantity, + &taskPrice); err != nil { return StartResult{}, false, err } + if !ValidAuthorizationFields(authorizationGoodsID, authorizationColor, authorizationSize, authorizationPrice) || + authorizationQuantity <= 0 || + !ValidTaskWireFields(taskTitle, taskGoodsID, taskColor, taskSize, taskPrice) || taskQuantity <= 0 || + authorizationGoodsID != taskGoodsID || authorizationColor != taskColor || + authorizationSize != taskSize || authorizationQuantity != taskQuantity || authorizationPrice != taskPrice { + return StartResult{}, false, ErrStartConflict + } item.ExpiresAt, err = time.Parse(time.RFC3339Nano, expires) if err != nil { return StartResult{}, false, err diff --git a/admin/internal/tasks/store.go b/admin/internal/tasks/store.go index ecdc36b..973693d 100644 --- a/admin/internal/tasks/store.go +++ b/admin/internal/tasks/store.go @@ -37,6 +37,11 @@ func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) { } func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) { + // Validate again at the persistence boundary. HTTP form validation is not the only + // caller, and a malformed row here would later make an authorized claim unencodable. + if !validUUID(draft.ID) || !ValidTaskWireFields(draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.MaxTotalPrice) || draft.Quantity <= 0 { + return Draft{}, ErrInvalidDraft + } writeContext, cancel := context.WithTimeout(ctx, sqliteWriteTimeout) defer cancel() // SQLite permits one writer at a time. Serializing this store's short create diff --git a/admin/internal/tasks/tasks.go b/admin/internal/tasks/tasks.go index e7262e2..8382edd 100644 --- a/admin/internal/tasks/tasks.go +++ b/admin/internal/tasks/tasks.go @@ -9,14 +9,21 @@ import ( "strconv" "strings" "time" + "unicode/utf8" ) const ( - maxTitleLength = 120 - maxSKUText = 80 + MaxTitleCodePoints = 120 + MaxSKUTextCodePoints = 80 + MaxGoodsIDCharacters = 32 + MaxMoneyASCIICharacters = 32 + maxSKUText = MaxSKUTextCodePoints ) -var ErrCreateKeyConflict = errors.New("create key conflicts with a different task") +var ( + ErrCreateKeyConflict = errors.New("create key conflicts with a different task") + ErrInvalidDraft = errors.New("invalid draft") +) type Draft struct { ID string @@ -41,13 +48,13 @@ func Validate(form Form) (Draft, Errors) { if !validUUID(draft.ID) { errors["create_key"] = "创建请求已过期,请重新打开表单。" } - if draft.Title == "" || len([]rune(draft.Title)) > maxTitleLength { + if !validBoundedText(draft.Title, MaxTitleCodePoints) { errors["title"] = "任务名称不能为空,且不能超过 120 个字符。" } - if draft.SKUColor == "" || len([]rune(draft.SKUColor)) > maxSKUText { + if !validBoundedText(draft.SKUColor, MaxSKUTextCodePoints) { errors["sku_color"] = "颜色分类不能为空,且不能超过 80 个字符。" } - if draft.SKUSize == "" || len([]rune(draft.SKUSize)) > maxSKUText { + if !validBoundedText(draft.SKUSize, MaxSKUTextCodePoints) { errors["sku_size"] = "尺码不能为空,且不能超过 80 个字符。" } goodsID, ok := CanonicalGoodsID(strings.TrimSpace(form.ProductURL)) @@ -93,6 +100,9 @@ func CanonicalGoodsID(value string) (string, bool) { return "", false } } + if !ValidGoodsID(goodsIDs[0]) { + return "", false + } return goodsIDs[0], true } @@ -155,5 +165,43 @@ func normalizeMoney(value string) (string, bool) { if whole == "0" && strings.Trim(fraction, "0") == "" { return "", false } - return whole + "." + (fraction + "00")[:2], true + canonical := whole + "." + (fraction + "00")[:2] + if len(canonical) > MaxMoneyASCIICharacters { + return "", false + } + return canonical, true +} + +// ValidTaskWireFields is shared by creation, authorization and claim. Keeping one +// bounded domain prevents a database row from being valid in one stage but impossible +// to encode inside the fixed claim response budget in another stage. +func ValidTaskWireFields(title, goodsID, skuColor, skuSize, maxTotalPrice string) bool { + return validBoundedText(title, MaxTitleCodePoints) && + ValidAuthorizationFields(goodsID, skuColor, skuSize, maxTotalPrice) +} + +func ValidAuthorizationFields(goodsID, skuColor, skuSize, totalPriceCap string) bool { + return ValidGoodsID(goodsID) && + validBoundedText(skuColor, MaxSKUTextCodePoints) && + validBoundedText(skuSize, MaxSKUTextCodePoints) && + ValidCanonicalMoney(totalPriceCap) +} + +func ValidGoodsID(value string) bool { + if value == "" || len(value) > MaxGoodsIDCharacters { + return false + } + for index := 0; index < len(value); index++ { + if value[index] < '0' || value[index] > '9' { + return false + } + } + return true +} + +func validBoundedText(value string, maximum int) bool { + // RuneCountInString replaces malformed byte sequences with RuneError. Validate first + // so corrupt SQLite text cannot consume the code-point budget as if it were legitimate. + return utf8.ValidString(value) && value != "" && strings.TrimSpace(value) == value && + utf8.RuneCountInString(value) <= maximum } diff --git a/admin/internal/tasks/tasks_test.go b/admin/internal/tasks/tasks_test.go index 36dcf82..555302d 100644 --- a/admin/internal/tasks/tasks_test.go +++ b/admin/internal/tasks/tasks_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "regexp" "runtime" + "strings" "sync" "testing" "time" @@ -38,13 +39,16 @@ func TestValidateNormalizesManualDraft(t *testing.T) { func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) { base := Form{CreateKey: testKey, Title: "title", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", SKUColor: "black", SKUSize: "M", Quantity: "1", MaxTotalPrice: "1"} for name, update := range map[string]func(*Form){ - "empty title": func(form *Form) { form.Title = " " }, - "long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) }, - "fraction quantity": func(form *Form) { form.Quantity = "1.5" }, - "zero quantity": func(form *Form) { form.Quantity = "0" }, - "too many decimals": func(form *Form) { form.MaxTotalPrice = "1.234" }, - "trailing decimal": func(form *Form) { form.MaxTotalPrice = "1." }, - "zero money": func(form *Form) { form.MaxTotalPrice = "0.00" }, + "empty title": func(form *Form) { form.Title = " " }, + "invalid utf8 title": func(form *Form) { form.Title = string([]byte{0xff}) }, + "long title": func(form *Form) { form.Title = strings.Repeat("😀", MaxTitleCodePoints+1) }, + "long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) }, + "invalid utf8 size": func(form *Form) { form.SKUSize = string([]byte{0xff}) }, + "fraction quantity": func(form *Form) { form.Quantity = "1.5" }, + "zero quantity": func(form *Form) { form.Quantity = "0" }, + "too many decimals": func(form *Form) { form.MaxTotalPrice = "1.234" }, + "trailing decimal": func(form *Form) { form.MaxTotalPrice = "1." }, + "zero money": func(form *Form) { form.MaxTotalPrice = "0.00" }, } { t.Run(name, func(t *testing.T) { form := base @@ -67,6 +71,7 @@ func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) { "https://mobile.yangkeduo.com/goods.html?goods_id=1%26goods_id%3D2", "https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=bad", "https://mobile.yangkeduo.com/other.html?goods_id=1", + "https://mobile.yangkeduo.com/goods.html?goods_id=" + strings.Repeat("1", MaxGoodsIDCharacters+1), } { if _, ok := CanonicalGoodsID(value); ok { t.Fatalf("CanonicalGoodsID accepted %q", value) @@ -75,19 +80,33 @@ func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) { } func TestNormalizeMoneyBoundaries(t *testing.T) { - for value, want := range map[string]string{"1": "1.00", "1.2": "1.20", "000.01": "0.01", "999999999999999999": "999999999999999999.00"} { + maximum := strings.Repeat("9", MaxMoneyASCIICharacters-3) + ".00" + for value, want := range map[string]string{"1": "1.00", "1.2": "1.20", "000.01": "0.01", "999999999999999999": "999999999999999999.00", maximum: maximum} { got, ok := normalizeMoney(value) if !ok || got != want { t.Fatalf("normalizeMoney(%q) = (%q, %t), want (%q, true)", value, got, ok, want) } } - for _, value := range []string{"0", "0.0", "0.00", "1.", ".1", "1.000", "-1", "1e2", " 1"} { + for _, value := range []string{"0", "0.0", "0.00", "1.", ".1", "1.000", "-1", "1e2", " 1", strings.Repeat("9", MaxMoneyASCIICharacters-2) + ".00"} { if got, ok := normalizeMoney(value); ok { t.Fatalf("normalizeMoney(%q) = %q, want rejection", value, got) } } } +func TestValidateAcceptsWorstLegalUnicodeFieldBounds(t *testing.T) { + goodsID := strings.Repeat("1", MaxGoodsIDCharacters) + draft, validation := Validate(Form{ + CreateKey: testKey, Title: strings.Repeat("😀", MaxTitleCodePoints), + ProductURL: CanonicalURL(goodsID), SKUColor: strings.Repeat("色", MaxSKUTextCodePoints), + SKUSize: strings.Repeat("码", MaxSKUTextCodePoints), Quantity: "1", + MaxTotalPrice: strings.Repeat("9", MaxMoneyASCIICharacters-3) + ".00", + }) + if !validation.Valid() || !ValidTaskWireFields(draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.MaxTotalPrice) { + t.Fatalf("worst legal draft = %#v, validation = %#v", draft, validation) + } +} + func TestNewCreateKeyIsUUIDv4(t *testing.T) { key, err := NewCreateKey() if err != nil { @@ -157,6 +176,36 @@ func TestSQLiteStoreCreatesListsAndHandlesIdempotency(t *testing.T) { } } +func TestSQLiteStoreRejectsInvalidDraftAtPersistenceBoundary(t *testing.T) { + mutations := map[string]func(*Draft){ + "untrimmed title": func(draft *Draft) { draft.Title = " title" }, + "invalid utf8 title": func(draft *Draft) { draft.Title = string([]byte{0xff}) }, + "long title": func(draft *Draft) { draft.Title = strings.Repeat("😀", MaxTitleCodePoints+1) }, + "long color": func(draft *Draft) { draft.SKUColor = strings.Repeat("色", MaxSKUTextCodePoints+1) }, + "long size": func(draft *Draft) { draft.SKUSize = strings.Repeat("码", MaxSKUTextCodePoints+1) }, + "long goods id": func(draft *Draft) { draft.GoodsID = strings.Repeat("1", MaxGoodsIDCharacters+1) }, + "long money": func(draft *Draft) { draft.MaxTotalPrice = strings.Repeat("1", MaxMoneyASCIICharacters-2) + ".00" }, + } + for name, mutate := range mutations { + t.Run(name, func(t *testing.T) { + database := migratedDatabase(t) + store, err := NewSQLiteStore(database) + if err != nil { + t.Fatal(err) + } + draft := testDraft(testKey, "title") + mutate(&draft) + if _, err := store.CreateDraft(context.Background(), draft); !errors.Is(err, ErrInvalidDraft) { + t.Fatalf("CreateDraft error = %v, want ErrInvalidDraft", err) + } + var count int + if err := database.QueryRow("SELECT COUNT(*) FROM tasks").Scan(&count); err != nil || count != 0 { + t.Fatalf("tasks after invalid create = %d, err %v", count, err) + } + }) + } +} + func TestSQLiteStoreRollsBackFailedCreate(t *testing.T) { database := migratedDatabase(t) store, err := NewSQLiteStore(database) diff --git a/client/src/cmbuyer_client/core/models.py b/client/src/cmbuyer_client/core/models.py index e537e8f..7003301 100644 --- a/client/src/cmbuyer_client/core/models.py +++ b/client/src/cmbuyer_client/core/models.py @@ -8,6 +8,8 @@ from pathlib import Path from .errors import ValidationError from .validation import ( + MAX_SKU_TEXT_CODE_POINTS, + MAX_TITLE_CODE_POINTS, canonical_product_url, require_exact_fields, require_goods_id, @@ -83,14 +85,18 @@ class PurchaseTask: def __post_init__(self) -> None: require_uuid4(self.id, "invalid_task_id") require_positive_int(self.version, "invalid_task_version") - require_string(self.title, "invalid_task_title", maximum=32 * 1024) - if not self.title.strip(): + require_string(self.title, "invalid_task_title", maximum=MAX_TITLE_CODE_POINTS) + if not self.title.strip() or self.title.strip() != self.title: raise ValidationError("invalid_task_title") require_goods_id(self.goods_id) if self.product_url != canonical_product_url(self.goods_id): raise ValidationError("invalid_product_url") - require_string(self.sku_color, "invalid_sku_color", maximum=32 * 1024) - require_string(self.sku_size, "invalid_sku_size", maximum=32 * 1024) + require_string(self.sku_color, "invalid_sku_color", maximum=MAX_SKU_TEXT_CODE_POINTS) + require_string(self.sku_size, "invalid_sku_size", maximum=MAX_SKU_TEXT_CODE_POINTS) + if self.sku_color.strip() != self.sku_color: + raise ValidationError("invalid_sku_color") + if self.sku_size.strip() != self.sku_size: + raise ValidationError("invalid_sku_size") require_positive_int(self.quantity, "invalid_quantity") require_money(self.max_total_price, "invalid_max_total_price") diff --git a/client/src/cmbuyer_client/core/validation.py b/client/src/cmbuyer_client/core/validation.py index c4655c8..90e82e8 100644 --- a/client/src/cmbuyer_client/core/validation.py +++ b/client/src/cmbuyer_client/core/validation.py @@ -21,6 +21,10 @@ RFC3339_Z_RE = re.compile( ) MONEY_RE = re.compile(r"(?:0|[1-9][0-9]*)\.[0-9]{2}") GOODS_ID_RE = re.compile(r"[0-9]+") +MAX_TITLE_CODE_POINTS = 120 +MAX_SKU_TEXT_CODE_POINTS = 80 +MAX_GOODS_ID_ASCII_CHARACTERS = 32 +MAX_MONEY_ASCII_CHARACTERS = 32 def require_string(value: object, reason: str, *, maximum: int = 4096) -> str: @@ -92,14 +96,14 @@ def require_positive_int(value: object, reason: str = "invalid_integer") -> int: def require_money(value: object, reason: str = "invalid_money") -> str: - text = require_string(value, reason, maximum=32 * 1024) + text = require_string(value, reason, maximum=MAX_MONEY_ASCII_CHARACTERS) if MONEY_RE.fullmatch(text) is None or text == "0.00": raise ValidationError(reason) return text def require_goods_id(value: object) -> str: - text = require_string(value, "invalid_goods_id", maximum=32 * 1024) + text = require_string(value, "invalid_goods_id", maximum=MAX_GOODS_ID_ASCII_CHARACTERS) if GOODS_ID_RE.fullmatch(text) is None: raise ValidationError("invalid_goods_id") return text diff --git a/client/tests/core/test_models.py b/client/tests/core/test_models.py index 9c10bb7..87ff739 100644 --- a/client/tests/core/test_models.py +++ b/client/tests/core/test_models.py @@ -100,15 +100,9 @@ class CoreModelsTests(unittest.TestCase): changed["task"]["max_total_price"] = invalid ClaimedTask.from_wire(changed) - wide = claim_wire() - wide_goods = "1" * 33 - wide["task"].update( - goods_id=wide_goods, - product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + wide_goods, - max_total_price="1" * 31 + ".00", - quantity=2_147_483_648, - ) - self.assertEqual(ClaimedTask.from_wire(wide).task.quantity, 2_147_483_648) + wide_quantity = claim_wire() + wide_quantity["task"]["quantity"] = 2_147_483_648 + self.assertEqual(ClaimedTask.from_wire(wide_quantity).task.quantity, 2_147_483_648) for invalid_goods in ("123", "1٢3"): changed = claim_wire() changed["task"]["goods_id"] = invalid_goods @@ -120,6 +114,47 @@ class CoreModelsTests(unittest.TestCase): with self.assertRaises(ValidationError): ClaimedTask.from_wire(too_large) + def test_claim_fields_share_explicit_server_bounds(self) -> None: + legal = claim_wire() + legal_goods = "1" * 32 + legal["task"].update( + title="😀" * 120, + goods_id=legal_goods, + product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + legal_goods, + sku_color="色" * 80, + sku_size="码" * 80, + max_total_price="1" * 29 + ".00", + ) + claimed = ClaimedTask.from_wire(legal) + self.assertEqual(len(claimed.task.title), 120) + # Python's default ensure_ascii=True expands astral characters to surrogate + # escape pairs, so this is a conservative parser-budget proof as well. + self.assertLess(len(json.dumps(legal, separators=(",", ":")).encode()), 32 * 1024) + + mutations = ( + ("title", "😀" * 121), + ("title", " title"), + ("sku_color", "色" * 81), + ("sku_color", "black "), + ("sku_size", "码" * 81), + ("sku_size", " M"), + ("max_total_price", "1" * 30 + ".00"), + ) + for field, invalid in mutations: + changed = claim_wire() + changed["task"][field] = invalid + with self.subTest(field=field, length=len(invalid)), self.assertRaises(ValidationError): + ClaimedTask.from_wire(changed) + + overlong_goods = "1" * 33 + changed = claim_wire() + changed["task"].update( + goods_id=overlong_goods, + product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + overlong_goods, + ) + with self.assertRaises(ValidationError): + ClaimedTask.from_wire(changed) + def test_wire_strings_reject_lone_surrogates_but_accept_valid_pair(self) -> None: for escaped in (r'"\ud800"', r'"\udc00"'): value = claim_wire() diff --git a/client/tests/remote/test_task_source.py b/client/tests/remote/test_task_source.py index 4708695..ca30e02 100644 --- a/client/tests/remote/test_task_source.py +++ b/client/tests/remote/test_task_source.py @@ -75,6 +75,32 @@ class TaskSourceTests(unittest.TestCase): HttpTaskSource(redirect).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID)) self.assertEqual(len(redirect.calls), 1) + def test_claim_rejects_service_field_bound_drift_as_ambiguous(self) -> None: + mutations = ( + ("title", "😀" * 121), + ("sku_color", "色" * 81), + ("sku_size", "码" * 81), + ("max_total_price", "1" * 30 + ".00"), + ) + for field, invalid in mutations: + value = claim_wire() + value["task"][field] = invalid + transport = FakeTransport(response(200, value)) + with self.subTest(field=field), self.assertRaises(AmbiguousRemoteError): + HttpTaskSource(transport).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID)) + self.assertEqual(len(transport.calls), 1) + + goods_id = "1" * 33 + value = claim_wire() + value["task"].update( + goods_id=goods_id, + product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + goods_id, + ) + with self.assertRaises(AmbiguousRemoteError): + HttpTaskSource(FakeTransport(response(200, value))).claim_next( + self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID) + ) + def test_fixed_conflict_and_renew_cas(self) -> None: conflict = FakeTransport(response(409, {"error": "claim_requires_manual"})) with self.assertRaises(ManualRemoteError): diff --git a/docs/api.md b/docs/api.md index 81c9a95..1dab938 100644 --- a/docs/api.md +++ b/docs/api.md @@ -96,6 +96,10 @@ ``` - 服务端解析并保存 canonical URL 与 `goods_id`;URL 非拼多多商品页、`goods_id` 缺失或含歧义则拒绝。 +- `title` 以 `TrimSpace` 后持久化值计,最多 120 个 Unicode code point;`sku_color`、`sku_size` + 同样按持久化值计,各最多 80 个 Unicode code point。非法 UTF-8 必须先拒绝,不能把替换字符当作 + 合法 code point;超限不得截断。 +- `goods_id` 只允许 1--32 位 ASCII 数字;规范金额只允许 1--32 个 ASCII 字符。 - `max_total_price` 是本任务允许创建待付款订单的总额上限,不是参考单价。 - 成功只产生 `DRAFT`;不得创建授权、开放设备领取或触发真机。 @@ -219,7 +223,12 @@ - 同一 `claim_request_id` 同设备、同 session 稳定重放原结果;同键异载荷返回 `409 {"error":"idempotency_conflict"}`。没有候选返回空 `204`,且 EMPTY 也持久化稳定重放。 - claim 持久化完整成功响应快照;领取后的 task/authorization 源行变化不得让旧 request 的标题、规格、 - 数量、金额、版本或到期时间漂移。源快照不一致时,新恢复/续租失败闭合。 + 数量、金额、版本或到期时间漂移。每次首次构造和旧 request 重放都重新校验持久化响应快照的字段 + 上限;源快照不一致时,新恢复/续租失败闭合。 +- 响应内 `title` 最多 120 个 Unicode code point,`sku_color` / `sku_size` 各最多 80 个;`goods_id` + 为 1--32 位 ASCII 数字,`max_total_price` 为最多 32 个 ASCII 字符的规范金额。创建、授权快照、 + candidate、持久化 claim snapshot 和 HTTP 输出共用同一合法域;既有畸形行只失败闭合,不迁移、 + 截断或改写。最坏合法字段组合编码后必须明确小于既有 32 KiB claim 响应上限。 - 一个设备最多有一个未关闭 claim。同 session 且租约有效时重放原 attempt;同一 attempt 已按服务端 首事件原子进入 `ORDERING` 时也只在 task version 恰好为 claim 版本 +1 时恢复。不同 session、租约 过期或业务状态异常固定返回 `409 {"error":"claim_requires_manual"}`,不释放、不转领、不新建 attempt。 @@ -365,9 +374,11 @@ claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限 ### 文本和金额校验 -- 规格字段:Unicode 规范化后精确相等;不得包含、前缀、编辑距离或 AI 猜测。 -- `goods_id`:仅 ASCII 十进制数字,canonical URL 中唯一。 -- 金额:`0.01` 到系统配置上限,至多两位小数;规范化后再比较和持久化。 +- 标题/规格字段:输入按既有 `TrimSpace` 形成实际持久化值;title 最多 120 个 Unicode code point, + 颜色与尺码各最多 80 个。服务端先拒绝非法 UTF-8,再计 code point;不按 UTF-8 字节或视觉 grapheme + 计数,不截断超限值。规格比较仍为规范化后精确相等;不得包含、前缀、编辑距离或 AI 猜测。 +- `goods_id`:仅 1--32 位 ASCII 十进制数字,canonical URL 中唯一。 +- 金额:`0.01` 到系统配置上限,至多两位小数;规范化后必须是最多 32 个 ASCII 字符,再比较和持久化。 - 数量:正整数,服务端与设备均设置合理上限;不能从字符串静默截断。 ## 三、采购工具本地模块合约 @@ -389,9 +400,11 @@ events/fail/fence/result 或完整 `ResultSink`。T-304/T-306 必须通过 `Dura 同一 device id 的 Bearer 后显式重放;网络、超时、503、截断、非法/未知 2xx 同样只保留原槽。协议/409 终止槽但不换 key。成功响应落库失败时,重启仍用原 key 向服务端恢复事实。 -金额按服务端合法域接受规范 ASCII 十进制正数字符串(最低 `0.01`,恰好两位小数、无前导零);wire -整数为正 int64,拒绝 bool。claim 成功响应总上限 32 KiB;因此客户端不额外发明 goods/title/SKU/金额 -的单字段业务上限。RFC3339Nano 按 0--9 位小数的纳秒时间轴比较,不能用 Python 微秒精度截断。 +金额按服务端合法域接受规范 ASCII 十进制正数字符串(最低 `0.01`,恰好两位小数、无前导零,最多 +32 个 ASCII 字符);`goods_id` 只接受 1--32 位 ASCII 数字,title 最多 120 个 Unicode code point, +颜色与尺码各最多 80 个。客户端不得截断或修复漂移响应。wire 整数为正 int64,拒绝 bool。claim 成功 +响应总上限仍为 32 KiB,最坏合法字段组合由双端契约测试证明严格小于该值。RFC3339Nano 按 0--9 位 +小数的纳秒时间轴比较,不能用 Python 微秒精度截断。 ### 本地恢复合约 diff --git a/docs/tasks/T-211.md b/docs/tasks/T-211.md index ad6f6a7..d624811 100644 --- a/docs/tasks/T-211.md +++ b/docs/tasks/T-211.md @@ -22,7 +22,7 @@ write_paths: - client/tests/remote/** --- - + ## 问题 / 背景 claim 成功响应受 32 KiB 总 body 上限保护,但创建、授权快照、candidate 和重放链路尚未统一约束各自由文本字段,客户端也未冻结同一单字段合法域。异常或历史超长值可能让合法业务事实变成不可传输响应。 @@ -65,6 +65,10 @@ claim 成功响应受 32 KiB 总 body 上限保护,但创建、授权快照、 ### 2026-08-04T17:18:04Z · ila 2026-08-05 实现范围收紧:HTTP handler 是 service 输出的最后边界,已获总控批准将精确文件 admin/internal/server/task_claims.go 纳入 write_paths;handler 序列化前重验 claim response 与 32 KiB 上限,不扩大到其他 server 文件。 + +### 2026-08-04T17:25:07Z · ila + +2026-08-05 实现里程碑:服务端共享 validator 已覆盖创建持久化、start/authorization、candidate、claim snapshot/replay 和 handler 输出;客户端 wire 同界。最坏合法服务端 JSON 使用会被 escapeHTML 为 6 字节的 < 字符证明严格小于 32 KiB,客户端用 ensure_ascii=True 做保守预算证明。focused:Go tasks/taskclaim/server 全绿;client core 8/8、remote 15/15。 ## 边界