mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
e1b1c7ec5b
Anthropic rejects inline images over 5,242,880 bytes, but our upload endpoint accepts images up to 10 MiB — so 5–10 MiB images were reaching the provider and failing. This adds two layers of protection: the browser resizes oversized images before upload, and the server rejects any that still slip through before an upstream request is issued. Client-side resizing uses `createImageBitmap` with `resizeWidth`/`resizeHeight` to clamp the decoded bitmap at decode time, then iteratively shrinks on an `OffscreenCanvas` (falling back to `HTMLCanvasElement`) until the output fits the applicable budget. Anthropic (and Bedrock-hosted Claude — fantasy's bedrock provider is a thin wrapper around the Anthropic client) uses a ~5 MiB budget; other providers use a ~10 MiB budget to stay under the server cap. Doing the resize in the browser avoids decoding attacker-controlled image bytes in `coderd` (image-bomb DoS surface). Server-side, `chatFileResolver` now takes a provider string and looks up the inline-image cap via a new `chatprovider.InlineImageByteCap` helper; oversized `image/*` files for capped providers are rejected with a pre-classified `chaterror` before the SDK call. The backstop fires for older clients, direct API callers, or any image that was committed to the composer before the user switched to a stricter provider. Attachments commit to composer state synchronously with a new `"processing"` `UploadState` so paste+Enter can't dispatch before the resize finishes; the `"uploading"` send gate now covers both states. Dismissed-while-resizing attachments are tracked in a `WeakSet` so a late swap can't resurrect a removed file. Closes CODAGT-215
314 lines
9.3 KiB
Go
314 lines
9.3 KiB
Go
package chatd
|
||
|
||
import (
|
||
"context"
|
||
"strconv"
|
||
"testing"
|
||
|
||
"github.com/dustin/go-humanize"
|
||
"github.com/google/uuid"
|
||
"github.com/stretchr/testify/require"
|
||
"go.uber.org/mock/gomock"
|
||
"golang.org/x/xerrors"
|
||
|
||
"github.com/coder/coder/v2/coderd/database"
|
||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||
"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
|
||
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
|
||
"github.com/coder/coder/v2/codersdk"
|
||
)
|
||
|
||
// inlineImageCapFor returns the provider's inline image cap. Fails
|
||
// the test if the provider has no documented cap.
|
||
func inlineImageCapFor(t *testing.T, provider string) int {
|
||
t.Helper()
|
||
imageCap, ok := chatprovider.InlineImageCapBytes(provider)
|
||
require.Truef(t, ok, "expected provider %q to have an inline image cap", provider)
|
||
return imageCap
|
||
}
|
||
|
||
// TestChatFileResolver_RejectsOversizedImages is the server-side
|
||
// safety net for browser-side resize: oversize images that reach the
|
||
// resolver are rejected before any upstream request.
|
||
func TestChatFileResolver_RejectsOversizedImages(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
// Computed so the table tracks any future cap retune.
|
||
anthropicCap := inlineImageCapFor(t, "anthropic")
|
||
|
||
tests := []struct {
|
||
name string
|
||
provider string
|
||
mimetype string
|
||
size int
|
||
expectReject bool
|
||
expectProviderID string // classified.Provider after normalization
|
||
}{
|
||
{
|
||
name: "OversizedAnthropicPNG_Rejected",
|
||
provider: "anthropic",
|
||
mimetype: "image/png",
|
||
size: anthropicCap + 1,
|
||
expectReject: true,
|
||
expectProviderID: "anthropic",
|
||
},
|
||
{
|
||
name: "OversizedAnthropicJPEG_Rejected",
|
||
provider: "anthropic",
|
||
mimetype: "image/jpeg",
|
||
size: anthropicCap + 1024,
|
||
expectReject: true,
|
||
expectProviderID: "anthropic",
|
||
},
|
||
{
|
||
// Boundary is >=: exactly-at-limit is rejected.
|
||
// Anthropic's docs say "5 MB maximum" without
|
||
// specifying inclusivity, so reject strictly.
|
||
name: "AtLimitAnthropicImage_Rejected",
|
||
provider: "anthropic",
|
||
mimetype: "image/png",
|
||
size: anthropicCap,
|
||
expectReject: true,
|
||
expectProviderID: "anthropic",
|
||
},
|
||
{
|
||
name: "JustUnderLimitAnthropicImage_Accepted",
|
||
provider: "anthropic",
|
||
mimetype: "image/png",
|
||
size: anthropicCap - 1,
|
||
expectReject: false,
|
||
expectProviderID: "anthropic",
|
||
},
|
||
{
|
||
name: "UndersizedAnthropicImage_Accepted",
|
||
provider: "anthropic",
|
||
mimetype: "image/png",
|
||
size: 1024,
|
||
expectReject: false,
|
||
expectProviderID: "anthropic",
|
||
},
|
||
{
|
||
// Bedrock reuses Anthropic's cap.
|
||
name: "OversizedBedrockPNG_Rejected",
|
||
provider: "bedrock",
|
||
mimetype: "image/png",
|
||
size: anthropicCap + 1,
|
||
expectReject: true,
|
||
expectProviderID: "bedrock",
|
||
},
|
||
{
|
||
name: "OversizedOpenAIImage_Accepted",
|
||
provider: "openai",
|
||
mimetype: "image/png",
|
||
size: anthropicCap + 1,
|
||
expectReject: false,
|
||
expectProviderID: "openai",
|
||
},
|
||
{
|
||
name: "OversizedAnthropicText_Accepted",
|
||
provider: "anthropic",
|
||
mimetype: "text/plain",
|
||
size: anthropicCap + 1,
|
||
expectReject: false,
|
||
expectProviderID: "anthropic",
|
||
},
|
||
{
|
||
name: "ProviderMixedCase_Rejected",
|
||
provider: "Anthropic",
|
||
mimetype: "image/png",
|
||
size: anthropicCap + 1,
|
||
expectReject: true,
|
||
expectProviderID: "anthropic",
|
||
},
|
||
{
|
||
name: "ProviderAllCaps_Rejected",
|
||
provider: "ANTHROPIC",
|
||
mimetype: "image/png",
|
||
size: anthropicCap + 1,
|
||
expectReject: true,
|
||
expectProviderID: "anthropic",
|
||
},
|
||
{
|
||
name: "ProviderPaddedWhitespace_Rejected",
|
||
provider: " anthropic ",
|
||
mimetype: "image/png",
|
||
size: anthropicCap + 1,
|
||
expectReject: true,
|
||
expectProviderID: "anthropic",
|
||
},
|
||
}
|
||
|
||
// One shared backing buffer sliced per case. The resolver only
|
||
// reads len(f.Data), so shared backing is safe and avoids N×max
|
||
// allocations in parallel.
|
||
maxSize := 0
|
||
for _, tc := range tests {
|
||
if tc.size > maxSize {
|
||
maxSize = tc.size
|
||
}
|
||
}
|
||
sharedData := make([]byte, maxSize)
|
||
|
||
for _, tc := range tests {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
|
||
ctrl := gomock.NewController(t)
|
||
db := dbmock.NewMockStore(ctrl)
|
||
server := &Server{db: db}
|
||
|
||
fileID := uuid.New()
|
||
row := database.ChatFile{
|
||
ID: fileID,
|
||
Name: "attachment.png",
|
||
Mimetype: tc.mimetype,
|
||
Data: sharedData[:tc.size],
|
||
}
|
||
db.EXPECT().
|
||
GetChatFilesByIDs(gomock.Any(), []uuid.UUID{fileID}).
|
||
Return([]database.ChatFile{row}, nil).
|
||
Times(1)
|
||
|
||
resolver := server.chatFileResolver(tc.provider)
|
||
got, err := resolver(ctx, []uuid.UUID{fileID})
|
||
|
||
if tc.expectReject {
|
||
require.Error(t, err)
|
||
require.Nil(t, got)
|
||
// Classification turns the generic upstream error
|
||
// into an actionable user-facing message.
|
||
classified := chaterror.Classify(err)
|
||
require.Equal(t, codersdk.ChatErrorKindConfig, classified.Kind)
|
||
require.Equal(t, tc.expectProviderID, classified.Provider)
|
||
require.False(t, classified.Retryable)
|
||
// User-facing message names the provider and shows
|
||
// the cap in human units; raw byte count stays in
|
||
// the wrapped developer error.
|
||
displayName := chatprovider.ProviderDisplayName(tc.expectProviderID)
|
||
require.Contains(t, classified.Message, displayName)
|
||
imageCap := inlineImageCapFor(t, tc.expectProviderID)
|
||
//nolint:gosec // imageCap is a small positive constant defined in chatprovider.
|
||
require.Contains(t, classified.Message, humanize.IBytes(uint64(imageCap)))
|
||
require.NotContains(
|
||
t,
|
||
classified.Message,
|
||
strconv.Itoa(imageCap),
|
||
"user-facing message should not include raw bytes",
|
||
)
|
||
// Wrapped error preserves exact bytes for logs.
|
||
require.Contains(t, err.Error(), strconv.Itoa(imageCap))
|
||
return
|
||
}
|
||
require.NoError(t, err)
|
||
require.Contains(t, got, fileID)
|
||
require.Equal(t, row.Data, got[fileID].Data)
|
||
require.Equal(t, tc.mimetype, got[fileID].MediaType)
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestChatFileResolver_MultiFileFailsFastOnFirstOversized pins the
|
||
// "first bad file aborts the batch" contract.
|
||
func TestChatFileResolver_MultiFileFailsFastOnFirstOversized(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
|
||
ctrl := gomock.NewController(t)
|
||
db := dbmock.NewMockStore(ctrl)
|
||
server := &Server{db: db}
|
||
|
||
anthropicCap := inlineImageCapFor(t, "anthropic")
|
||
// Shared buffer; ok files take small prefixes.
|
||
buf := make([]byte, anthropicCap+1)
|
||
okFileA := database.ChatFile{
|
||
ID: uuid.New(),
|
||
Name: "ok-a.png",
|
||
Mimetype: "image/png",
|
||
Data: buf[:1024],
|
||
}
|
||
oversized := database.ChatFile{
|
||
ID: uuid.New(),
|
||
Name: "too-big.png",
|
||
Mimetype: "image/png",
|
||
Data: buf,
|
||
}
|
||
okFileB := database.ChatFile{
|
||
ID: uuid.New(),
|
||
Name: "ok-b.png",
|
||
Mimetype: "image/png",
|
||
Data: buf[:1024],
|
||
}
|
||
ids := []uuid.UUID{okFileA.ID, oversized.ID, okFileB.ID}
|
||
|
||
db.EXPECT().
|
||
GetChatFilesByIDs(gomock.Any(), ids).
|
||
Return([]database.ChatFile{okFileA, oversized, okFileB}, nil).
|
||
Times(1)
|
||
|
||
resolver := server.chatFileResolver("anthropic")
|
||
got, err := resolver(ctx, ids)
|
||
require.Error(t, err)
|
||
require.Nil(t, got)
|
||
classified := chaterror.Classify(err)
|
||
require.Equal(t, codersdk.ChatErrorKindConfig, classified.Kind)
|
||
// The error must identify the specific offending file so a user
|
||
// with several attachments knows which one to replace.
|
||
require.Contains(t, err.Error(), oversized.Name)
|
||
}
|
||
|
||
// TestChatFileResolver_PropagatesDBError confirms unrelated database
|
||
// failures pass through unchanged (not masked by the size check).
|
||
func TestChatFileResolver_PropagatesDBError(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
|
||
ctrl := gomock.NewController(t)
|
||
db := dbmock.NewMockStore(ctrl)
|
||
server := &Server{db: db}
|
||
|
||
sentinel := xerrors.New("boom")
|
||
fileID := uuid.New()
|
||
db.EXPECT().
|
||
GetChatFilesByIDs(gomock.Any(), []uuid.UUID{fileID}).
|
||
Return(nil, sentinel).
|
||
Times(1)
|
||
|
||
resolver := server.chatFileResolver("anthropic")
|
||
got, err := resolver(ctx, []uuid.UUID{fileID})
|
||
require.ErrorIs(t, err, sentinel)
|
||
require.Nil(t, got)
|
||
}
|
||
|
||
// TestChatFileResolver_UnknownProviderSkipsCapCheck confirms providers
|
||
// without a documented inline cap are never rejected by the backstop.
|
||
func TestChatFileResolver_UnknownProviderSkipsCapCheck(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
|
||
ctrl := gomock.NewController(t)
|
||
db := dbmock.NewMockStore(ctrl)
|
||
server := &Server{db: db}
|
||
|
||
fileID := uuid.New()
|
||
// Exactly 1 byte above the Anthropic cap is enough to prove
|
||
// the backstop is skipped for uncapped providers; no need to
|
||
// allocate tens of MiB in CI.
|
||
overAnyCap := inlineImageCapFor(t, "anthropic") + 1
|
||
row := database.ChatFile{
|
||
ID: fileID,
|
||
Name: "huge.png",
|
||
Mimetype: "image/png",
|
||
Data: make([]byte, overAnyCap),
|
||
}
|
||
db.EXPECT().
|
||
GetChatFilesByIDs(gomock.Any(), []uuid.UUID{fileID}).
|
||
Return([]database.ChatFile{row}, nil).
|
||
Times(1)
|
||
|
||
resolver := server.chatFileResolver("openrouter")
|
||
got, err := resolver(ctx, []uuid.UUID{fileID})
|
||
require.NoError(t, err)
|
||
require.Contains(t, got, fileID)
|
||
}
|