diff --git a/bundled_smoke_contract_test.go b/bundled_smoke_contract_test.go index 2d9c14f..92bdbd9 100644 --- a/bundled_smoke_contract_test.go +++ b/bundled_smoke_contract_test.go @@ -125,3 +125,17 @@ func TestBundledSmokeSelectionUsesEffectiveConfiguration(t *testing.T) { } } } + +func TestBundledSmokeAuthenticationResponseOverflow(t *testing.T) { + transport := roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(strings.Repeat("x", (1<<20)+1)))}, nil + }) + engine, err := promptkit.NewEngine(promptkit.Config{HTTPClient: &http.Client{Transport: transport}}) + if err != nil { + t.Fatal(err) + } + report, err := engine.SmokeTest(context.Background(), promptkit.SmokeTestRequest{Kind: promptkit.SmokeTestAuthentication, BackendID: "openrouter", APIKey: "private-key-marker"}) + if err != nil || report == nil || !report.Completed || report.Reason != promptkit.SmokeTestReasonAuthenticationResponseLimitExceeded || report.Failure != promptkit.ExecutionFailureOther || report.Facts.CredentialsAccepted != nil { + t.Fatalf("overflow = %+v, %v", report, err) + } +} diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 003822c..17ec1dc 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -224,6 +224,10 @@ limit plus one, missing/inaccurate lengths, malformed bounded bodies, closure, 401 without reading, cancellation, and redacted explicit overflow. Keep this fix independent of the later protocol relocation. +Completion: authentication recognition reads at most 1 MiB plus one byte and +reports a distinct overflow reason without asserting credential rejection. +Focused authentication and public smoke tests pass on Go 1.27.3. + ## Stage 8: Correct Artifact Result Ownership **Catalog:** 4. **Files:** public ArtifactReader GoDoc, adapter and ownership tests. diff --git a/internal/domain/smoke_diagnostics.go b/internal/domain/smoke_diagnostics.go index bb54f2f..d6b5133 100644 --- a/internal/domain/smoke_diagnostics.go +++ b/internal/domain/smoke_diagnostics.go @@ -112,12 +112,13 @@ type SmokeTestAttempt struct { type SmokeTestReason string const ( - SmokeTestReasonNone SmokeTestReason = "" - SmokeTestReasonAuthenticationNotApplicable SmokeTestReason = "authentication_not_applicable" - SmokeTestReasonProbeNotConfigured SmokeTestReason = "probe_not_configured" - SmokeTestReasonHTTPDiagnosticsUnavailable SmokeTestReason = "http_diagnostics_unavailable" - SmokeTestReasonRequiredCredentialMissing SmokeTestReason = "required_credential_missing" - SmokeTestReasonAuthenticationResponseUnrecognized SmokeTestReason = "authentication_response_unrecognized" + SmokeTestReasonNone SmokeTestReason = "" + SmokeTestReasonAuthenticationNotApplicable SmokeTestReason = "authentication_not_applicable" + SmokeTestReasonProbeNotConfigured SmokeTestReason = "probe_not_configured" + SmokeTestReasonHTTPDiagnosticsUnavailable SmokeTestReason = "http_diagnostics_unavailable" + SmokeTestReasonRequiredCredentialMissing SmokeTestReason = "required_credential_missing" + SmokeTestReasonAuthenticationResponseUnrecognized SmokeTestReason = "authentication_response_unrecognized" + SmokeTestReasonAuthenticationResponseLimitExceeded SmokeTestReason = "authentication_response_limit_exceeded" ) // CloneBackend returns a copy of a backend, including its mutable settings. diff --git a/internal/usecase/smoke_authentication.go b/internal/usecase/smoke_authentication.go index 78f053b..bee43c5 100644 --- a/internal/usecase/smoke_authentication.go +++ b/internal/usecase/smoke_authentication.go @@ -17,6 +17,8 @@ type authenticationProbe struct { client smokeHTTPGetter } +const maxAuthenticationResponseBytes = 1 << 20 + type authenticationProbeOutcome struct { supported bool applicable *bool @@ -104,7 +106,17 @@ func (p authenticationProbe) runOpenRouterKey( outcome.failure = domain.ExecutionFailureMalformedResponse return outcome, nil } - recognized, bodyErr := recognizedOpenRouterKeyResponse(response.Body) + contents, bodyErr := io.ReadAll(io.LimitReader(response.Body, maxAuthenticationResponseBytes+1)) + if len(contents) > maxAuthenticationResponseBytes { + outcome.reason = domain.SmokeTestReasonAuthenticationResponseLimitExceeded + outcome.failure = domain.ExecutionFailureOther + return outcome, nil + } + if bodyErr != nil { + outcome.failure = httpProbeFailure(ctx, bodyErr) + return outcome, nil + } + recognized, bodyErr := recognizedOpenRouterKeyResponse(bytes.NewReader(contents)) if bodyErr != nil { outcome.failure = httpProbeFailure(ctx, bodyErr) return outcome, nil diff --git a/internal/usecase/smoke_authentication_test.go b/internal/usecase/smoke_authentication_test.go index fd4cc93..8508fb0 100644 --- a/internal/usecase/smoke_authentication_test.go +++ b/internal/usecase/smoke_authentication_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "net/http" "strings" "testing" @@ -11,6 +12,57 @@ import ( "go.maximumdirect.net/promptkit/internal/domain" ) +type measuredAuthenticationBody struct { + reader io.Reader + read int + closed bool +} + +func (b *measuredAuthenticationBody) Read(p []byte) (int, error) { + n, err := b.reader.Read(p) + b.read += n + return n, err +} +func (b *measuredAuthenticationBody) Close() error { b.closed = true; return nil } + +func TestAuthenticationResponseRecognitionBound(t *testing.T) { + valid := `{"data":{"label":"member","is_free_tier":false,"usage":0}}` + for _, test := range []struct { + name string + size int + declared int64 + status int + overflow bool + }{ + {"exact limit", maxAuthenticationResponseBytes, -1, 200, false}, + {"overflow unknown length", maxAuthenticationResponseBytes + 10, -1, 200, true}, + {"overflow understated length", maxAuthenticationResponseBytes + 10, 1, 200, true}, + {"unauthorized does not read", maxAuthenticationResponseBytes + 10, -1, 401, false}, + } { + t.Run(test.name, func(t *testing.T) { + body := &measuredAuthenticationBody{reader: strings.NewReader(valid + strings.Repeat(" ", test.size-len(valid)))} + client := reachabilityClient(t, func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: test.status, Header: make(http.Header), Body: body, ContentLength: test.declared}, nil + }) + outcome, err := (authenticationProbe{client: client}).run(context.Background(), backendWithAuthenticationKind("openrouter_key"), domain.SmokeTestTarget{Endpoint: "https://example.invalid/key"}) + if err != nil || !body.closed { + t.Fatalf("outcome = %+v, err = %v, closed = %v", outcome, err, body.closed) + } + if test.overflow { + if outcome.reason != domain.SmokeTestReasonAuthenticationResponseLimitExceeded || outcome.failure != domain.ExecutionFailureOther || outcome.credentialsAccepted != nil || body.read != maxAuthenticationResponseBytes+1 { + t.Fatalf("overflow = %+v, read = %d", outcome, body.read) + } + } else if test.status == 401 { + if body.read != 0 || outcome.credentialsAccepted == nil || *outcome.credentialsAccepted { + t.Fatalf("unauthorized = %+v, read = %d", outcome, body.read) + } + } else if outcome.credentialsAccepted == nil || !*outcome.credentialsAccepted { + t.Fatalf("exact limit = %+v", outcome) + } + }) + } +} + func TestAuthenticationProbeApplicabilityAndHTTPAvailability(t *testing.T) { client := reachabilityClient(t, func(*http.Request) (*http.Response, error) { t.Fatal("authentication applicability unexpectedly dispatched") diff --git a/smoke_diagnostics.go b/smoke_diagnostics.go index 798775a..37c73e5 100644 --- a/smoke_diagnostics.go +++ b/smoke_diagnostics.go @@ -219,6 +219,9 @@ const ( // SmokeTestReasonAuthenticationResponseUnrecognized marks an HTTP response // whose authentication meaning could not be established. SmokeTestReasonAuthenticationResponseUnrecognized SmokeTestReason = "authentication_response_unrecognized" + // SmokeTestReasonAuthenticationResponseLimitExceeded indicates that the + // authentication response exceeded the recognition allowance. + SmokeTestReasonAuthenticationResponseLimitExceeded SmokeTestReason = "authentication_response_limit_exceeded" ) // SmokeTest runs one built-in diagnostic against the engine's captured