diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 3b5ecbb3dc..88b93338a9 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -5544,7 +5544,9 @@ func (api *API) postChatFile(rw http.ResponseWriter, r *http.Request) { if mediaType, _, err := mime.ParseMediaType(contentType); err == nil { contentType = mediaType } - if !chatfiles.IsAllowedStoredMediaType(contentType) { + // application/octet-stream means the client could not classify the file + // ahead of time, so we defer to byte classification below. + if contentType != "application/octet-stream" && !chatfiles.IsAllowedStoredMediaType(contentType) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Unsupported file type.", Detail: fmt.Sprintf("Allowed types: %s.", chatfiles.AllowedStoredMediaTypesString()), @@ -5602,12 +5604,12 @@ func (api *API) postChatFile(rw http.ResponseWriter, r *http.Request) { return } // The compatibility check below is security-critical: it keeps exact - // media-type matching by default while allowing safe text/plain - // refinements such as JSON, CSV, and Markdown now that upload - // classification can return richer stored media types. Combined with - // the X-Content-Type-Options: nosniff header applied globally, this - // still prevents clients from smuggling binary or active content under - // a safer declared Content-Type. + // media-type matching by default while allowing application/ + // octet-stream uploads to defer to byte classification, and letting + // text/plain refine to safe text subtypes such as JSON, CSV, and + // Markdown. Combined with the X-Content-Type-Options: nosniff header + // applied globally, this still prevents clients from smuggling binary + // or active content under a safer declared Content-Type. if !chatfiles.IsCompatibleUploadMediaType(contentType, detected) { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "File content type does not match Content-Type header.", diff --git a/coderd/exp_chats_test.go b/coderd/exp_chats_test.go index e8656853e2..68ea5f58ab 100644 --- a/coderd/exp_chats_test.go +++ b/coderd/exp_chats_test.go @@ -8654,6 +8654,54 @@ widgets,3 require.NotEqual(t, uuid.Nil, resp.ID) }) + t.Run("Success/OctetStreamPNG", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + + data := append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 64)...) + uploaded, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "application/octet-stream", "test.png", bytes.NewReader(data)) + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, uploaded.ID) + + got, contentType, err := client.GetChatFile(ctx, uploaded.ID) + require.NoError(t, err) + require.Equal(t, "image/png", contentType) + require.Equal(t, data, got) + }) + + t.Run("Success/OctetStreamMarkdown", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + + data := []byte(`# Markdown upload + +This arrived as octet-stream. +`) + uploaded, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "application/octet-stream", "notes.md", bytes.NewReader(data)) + require.NoError(t, err) + require.NotEqual(t, uuid.Nil, uploaded.ID) + + got, contentType, err := client.GetChatFile(ctx, uploaded.ID) + require.NoError(t, err) + require.Equal(t, "text/markdown", contentType) + require.Equal(t, data, got) + }) + + t.Run("OctetStreamRejectsUnsupportedBytes", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + client := newChatClient(t) + firstUser := coderdtest.CreateFirstUser(t, client.Client) + + _, err := client.UploadChatFile(ctx, firstUser.OrganizationID, "application/octet-stream", "payload.zip", bytes.NewReader([]byte("PK"))) + sdkErr := requireSDKError(t, err, http.StatusBadRequest) + require.Contains(t, sdkErr.Message, "Unsupported file type") + }) + t.Run("UnsupportedContentType", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitLong) diff --git a/coderd/x/chatfiles/mime.go b/coderd/x/chatfiles/mime.go index f9f7f8b6b3..122c10d3c5 100644 --- a/coderd/x/chatfiles/mime.go +++ b/coderd/x/chatfiles/mime.go @@ -13,6 +13,8 @@ import ( "github.com/gabriel-vasile/mimetype" "golang.org/x/xerrors" + + "github.com/coder/coder/v2/codersdk" ) const MaxStoredFileNameBytes = 255 @@ -28,17 +30,17 @@ var ( utf8BOM = []byte{0xEF, 0xBB, 0xBF} - allowedStoredMediaTypes = map[string]struct{}{ - "image/png": {}, - "image/jpeg": {}, - "image/gif": {}, - "image/webp": {}, - "text/plain": {}, - "text/markdown": {}, - "text/csv": {}, - "application/json": {}, - "application/pdf": {}, - } + // allowedStoredMediaTypes is derived from codersdk.AllChatAttachmentMediaTypes + // so the frontend file picker and the server enforcement share a single + // source of truth. Do not edit this map directly; add new entries to the + // codersdk const block instead. + allowedStoredMediaTypes = func() map[string]struct{} { + m := make(map[string]struct{}, len(codersdk.AllChatAttachmentMediaTypes)) + for _, t := range codersdk.AllChatAttachmentMediaTypes { + m[string(t)] = struct{}{} + } + return m + }() recordingArtifactMediaTypes = map[string]struct{}{ "video/mp4": {}, @@ -139,14 +141,16 @@ func PrepareRecordingArtifact(name, expectedMediaType string, data []byte) (stor // IsCompatibleUploadMediaType reports whether an upload request that declared // declaredMediaType may be stored as storedMediaType after byte -// classification. Exact matches are always compatible; the compatibility -// table only covers explicit refinements like text/plain uploads that safely -// store as richer text subtypes. +// classification. Exact matches are always compatible. Clients that declare +// application/octet-stream are treated as "unknown", so the classified bytes +// decide the stored type. The compatibility table also covers explicit +// refinements like text/plain uploads that safely store as richer text +// subtypes. func IsCompatibleUploadMediaType(declaredMediaType, storedMediaType string) bool { declaredMediaType = BaseMediaType(declaredMediaType) storedMediaType = BaseMediaType(storedMediaType) - if declaredMediaType == storedMediaType { + if declaredMediaType == storedMediaType || declaredMediaType == "application/octet-stream" { return true } if declaredMediaType != "text/plain" { diff --git a/coderd/x/chatfiles/mime_test.go b/coderd/x/chatfiles/mime_test.go index 4e42a5da6d..0949e37470 100644 --- a/coderd/x/chatfiles/mime_test.go +++ b/coderd/x/chatfiles/mime_test.go @@ -270,6 +270,18 @@ func TestIsCompatibleUploadMediaType(t *testing.T) { stored: "text/plain", want: true, }, + { + name: "OctetStreamMatchesPNG", + declared: "application/octet-stream", + stored: "image/png", + want: true, + }, + { + name: "OctetStreamMatchesJSON", + declared: "application/octet-stream", + stored: "application/json", + want: true, + }, { name: "TextPlainRefinesToMarkdown", declared: "text/plain", diff --git a/codersdk/chats.go b/codersdk/chats.go index e4709e13fd..7f829332b7 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -33,6 +33,40 @@ const ChatCompactionThresholdKeyPrefix = "chat_compaction_threshold_pct:" // this limit than to lower it. const MaxChatFileIDs = 20 +// ChatAttachmentMediaType is a media type that is allowed for durable +// chat file storage. The set is intentionally narrow; byte-level +// classification and inline-render rules live alongside the enforcement +// helpers in coderd/chatfiles. +type ChatAttachmentMediaType string + +const ( + ChatAttachmentMediaTypeApplicationJSON ChatAttachmentMediaType = "application/json" + ChatAttachmentMediaTypeApplicationPDF ChatAttachmentMediaType = "application/pdf" + ChatAttachmentMediaTypeImageGIF ChatAttachmentMediaType = "image/gif" + ChatAttachmentMediaTypeImageJPEG ChatAttachmentMediaType = "image/jpeg" + ChatAttachmentMediaTypeImagePNG ChatAttachmentMediaType = "image/png" + ChatAttachmentMediaTypeImageWEBP ChatAttachmentMediaType = "image/webp" + ChatAttachmentMediaTypeTextCSV ChatAttachmentMediaType = "text/csv" + ChatAttachmentMediaTypeTextMarkdown ChatAttachmentMediaType = "text/markdown" + ChatAttachmentMediaTypeTextPlain ChatAttachmentMediaType = "text/plain" +) + +// AllChatAttachmentMediaTypes enumerates every durable chat attachment +// media type in the same lexical order the guts-generated TypeScript +// list uses, so the frontend file picker and the backend enforcement +// map stay in lockstep. Add new values in sorted order. +var AllChatAttachmentMediaTypes = []ChatAttachmentMediaType{ + ChatAttachmentMediaTypeApplicationJSON, + ChatAttachmentMediaTypeApplicationPDF, + ChatAttachmentMediaTypeImageGIF, + ChatAttachmentMediaTypeImageJPEG, + ChatAttachmentMediaTypeImagePNG, + ChatAttachmentMediaTypeImageWEBP, + ChatAttachmentMediaTypeTextCSV, + ChatAttachmentMediaTypeTextMarkdown, + ChatAttachmentMediaTypeTextPlain, +} + // CompactionThresholdKey returns the user-config key for a specific // model configuration's compaction threshold. func CompactionThresholdKey(modelConfigID uuid.UUID) string { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a6a06ab366..39c5d2f56c 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1320,6 +1320,30 @@ export interface Chat { readonly children: readonly Chat[]; } +// From codersdk/chats.go +export type ChatAttachmentMediaType = + | "application/json" + | "application/pdf" + | "image/gif" + | "image/jpeg" + | "image/png" + | "image/webp" + | "text/csv" + | "text/markdown" + | "text/plain"; + +export const ChatAttachmentMediaTypes: ChatAttachmentMediaType[] = [ + "application/json", + "application/pdf", + "image/gif", + "image/jpeg", + "image/png", + "image/webp", + "text/csv", + "text/markdown", + "text/plain", +]; + // From codersdk/chats.go /** * ChatAutoArchiveDaysResponse contains the current chat auto-archive setting. diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index 8e762be27b..1bc2b5eb94 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -3,9 +3,9 @@ import { ArrowUpIcon, CheckIcon, ChevronRightIcon, - ImageIcon, MicIcon, MonitorIcon, + PaperclipIcon, PencilIcon, PlusIcon, ServerIcon, @@ -54,6 +54,10 @@ import { isBelowMdViewport, isMobileViewport } from "#/utils/mobile"; import { chatWidthClass, useChatFullWidth } from "../hooks/useChatFullWidth"; import { useOverflowCount } from "../hooks/useOverflowCount"; import { useSpeechRecognition } from "../hooks/useSpeechRecognition"; +import { + chatAttachmentAcceptAttribute, + isChatAttachmentFile, +} from "../utils/chatAttachments"; import { formatProviderLabel } from "../utils/modelOptions"; import { AttachmentPreview, @@ -535,7 +539,7 @@ export const AgentChatInput: FC = ({ } }; - // Drag-and-drop support for image files. + // Drag-and-drop support for any chat-supported file type. const [isDragging, setIsDragging] = useState(false); const handleDragOver = (e: React.DragEvent) => { @@ -555,11 +559,11 @@ export const AgentChatInput: FC = ({ e.preventDefault(); setIsDragging(false); if (!onAttach || !e.dataTransfer.files.length) return; - const images = Array.from(e.dataTransfer.files).filter((f) => - f.type.startsWith("image/"), + const attachable = Array.from(e.dataTransfer.files).filter( + isChatAttachmentFile, ); - if (images.length > 0) { - onAttach(images); + if (attachable.length > 0) { + onAttach(attachable); } }; @@ -826,13 +830,13 @@ export const AgentChatInput: FC = ({ )} - {/* Hidden file input for image attachment */} + {/* Hidden file input for attaching any server-accepted file type. */} {onAttach && ( @@ -918,8 +922,8 @@ export const AgentChatInput: FC = ({ }} className="group flex h-8 w-full cursor-pointer items-center gap-1.5 border-none bg-transparent px-1 text-xs text-content-secondary shadow-none transition-colors hover:text-content-primary" > - - Attach image + + Attach file )} {onPlanModeToggle && ( diff --git a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx index 2be8d6c671..df2da701ea 100644 --- a/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx +++ b/site/src/pages/AgentsPage/components/ChatMessageInput/ChatMessageInput.tsx @@ -31,6 +31,7 @@ import { } from "react"; import { cn } from "#/utils/cn"; import { isMobileViewport } from "#/utils/mobile"; +import { isChatAttachmentFile } from "../../utils/chatAttachments"; import { $createFileReferenceNode, FileReferenceNode, @@ -201,16 +202,16 @@ const PasteSanitizationPlugin: FC<{ } // Native paste event (ClipboardEvent). - // Check for image files in the clipboard (e.g. + // Check for attachable files in the clipboard (e.g. // pasted screenshots). Forward them to the parent // via callback instead of inserting text. if (onFilePaste && dataTransfer?.files.length) { - const images = Array.from(dataTransfer.files).filter((f) => - f.type.startsWith("image/"), + const attachable = Array.from(dataTransfer.files).filter( + isChatAttachmentFile, ); - if (images.length > 0) { + if (attachable.length > 0) { event.preventDefault(); - for (const file of images) { + for (const file of attachable) { onFilePaste(file); } return true; diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.test.ts b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts new file mode 100644 index 0000000000..4f6fad221b --- /dev/null +++ b/site/src/pages/AgentsPage/utils/chatAttachments.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { isChatAttachmentFile } from "./chatAttachments"; + +describe("isChatAttachmentFile", () => { + it("accepts allowlisted MIME types", () => { + const file = new File(["png"], "image.png", { type: "image/png" }); + + expect(isChatAttachmentFile(file)).toBe(true); + }); + + it("accepts files with an empty MIME type", () => { + const file = new File(["markdown"], "notes.md"); + + expect(isChatAttachmentFile(file)).toBe(true); + }); + + it("accepts application/octet-stream files", () => { + const file = new File(["unknown"], "attachment.bin", { + type: "application/octet-stream", + }); + + expect(isChatAttachmentFile(file)).toBe(true); + }); + + it("rejects unsupported MIME types", () => { + const file = new File(["zip"], "archive.zip", { + type: "application/zip", + }); + + expect(isChatAttachmentFile(file)).toBe(false); + }); +}); diff --git a/site/src/pages/AgentsPage/utils/chatAttachments.ts b/site/src/pages/AgentsPage/utils/chatAttachments.ts index db1a94c8e5..47992be4c1 100644 --- a/site/src/pages/AgentsPage/utils/chatAttachments.ts +++ b/site/src/pages/AgentsPage/utils/chatAttachments.ts @@ -1,4 +1,5 @@ import { isApiErrorResponse } from "#/api/errors"; +import { ChatAttachmentMediaTypes } from "#/api/typesGenerated"; const undisplayableAttachmentDetail = "File exists but could not be displayed."; @@ -60,3 +61,40 @@ export async function probeAttachmentFailure( const response = await fetch(src, { signal }); return classifyAttachmentFailureResponse(response); } + +// Filename extensions to list in the file-picker's `accept` attribute +// alongside the MIME types. Browsers and operating systems do not always +// map these extensions to a registered MIME type (Markdown is the common +// offender), so including the extensions keeps the corresponding files +// selectable. The server still classifies uploads by byte content. +const chatAttachmentExtraExtensions = [ + ".md", + ".markdown", + ".csv", + ".json", + ".txt", +] as const; + +/** + * `accept` attribute for the chat-attachment file input. Mirrors + * codersdk.AllChatAttachmentMediaTypes so the OS file picker advertises + * exactly what the server will accept. + */ +export const chatAttachmentAcceptAttribute = [ + ...ChatAttachmentMediaTypes, + ...chatAttachmentExtraExtensions, +].join(","); + +/** + * Returns true for files whose declared MIME type is on the server + * allowlist. Files whose type is unknown, either as an empty string or + * as application/octet-stream, also pass so dropped or pasted files can + * still reach the server, which remains the authority on attachment + * bytes. + */ +export const isChatAttachmentFile = (file: File): boolean => { + if (!file.type || file.type === "application/octet-stream") { + return true; + } + return ChatAttachmentMediaTypes.some((mediaType) => mediaType === file.type); +};