mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
fix: align chat attachment picker with allowed file types (#24917)
The agent chat composer only advertised image uploads to the OS file picker and filtered drag-and-drop and paste events to `image/*`, even though the backend accepts text, CSV, JSON, PDF, and a narrower set of image types. Move the allowed chat attachment media types into `codersdk` so the frontend picker and backend enforcement share one source of truth. Use the generated TypeScript list to drive the file input `accept` attribute and the drag-and-drop and paste filters, while adding common text extensions so platforms without MIME registrations still surface those files in the picker.
This commit is contained in:
+9
-7
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
+19
-15
@@ -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" {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+24
@@ -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.
|
||||
|
||||
@@ -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<AgentChatInputProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 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<AgentChatInputProps> = ({
|
||||
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<AgentChatInputProps> = ({
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
{/* Hidden file input for image attachment */}
|
||||
{/* Hidden file input for attaching any server-accepted file type. */}
|
||||
{onAttach && (
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
accept={chatAttachmentAcceptAttribute}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
@@ -918,8 +922,8 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<ImageIcon className="size-3.5 shrink-0" />
|
||||
Attach image
|
||||
<PaperclipIcon className="size-3.5 shrink-0" />
|
||||
Attach file
|
||||
</button>
|
||||
)}
|
||||
{onPlanModeToggle && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user