Root cause
Authentication & access control introduced
Support files uploaded to providers via new `UploadedFile` object (#3942)
How AI contributed: Direct introductionPublished Jul 29, 2026 in
pydantic/pydantic-ai
CVE-2026-54249 / GHSA-h7p7-w5gc-xj3w (PYSEC-2026-3692/3693, published ~2026-06, reporter via GitHub): UI adapters (Vercel AI and AG-UI) reconstruct file parts from client-submitted message history and forward them to model providers. FileUrl parts were validated against a scheme allowlist (allowed_file_url_schemes), but UploadedFile references - which point at a file by provider file ID or cloud-storage URI (s3://, gs://) - were forwarded with NO validation. The provider resolves an UploadedFile with server-side identity (IAM role / service account / provider API key), so a client that crafts message history containing an attacker-chosen UploadedFile can read arbitrary objects in the server's provider/cloud account (confused deputy / broken access control, CWE-284/863). Affected 1.65.0-1.105.0 and 2.0.0b1-b5; fixed in 1.106.0/2.0.0b6 by validating/dropping UploadedFile refs on client-submitted messages the same way file URLs are validated.
Root cause
Support files uploaded to providers via new `UploadedFile` object (#3942)
Fix
Handle `UploadedFile` consistently with `FileUrl` in UI adapters (#5772)
Case facts
@@ -43,6 +43,7 @@ from pydantic_ai._run_context import RunContext from pydantic_ai.builtin_tools import AbstractBuiltinTool, CodeExecutionTool from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError, UserError+from pydantic_ai.messages import UploadedFile from pydantic_ai.models import Model, ModelRequestParameters, StreamedResponse, download_item from pydantic_ai.providers import Provider, infer_provider from pydantic_ai.providers.bedrock import BedrockModelProfile, remove_bedrock_geo_prefix@@ -63,10 +64,8 @@ ConverseStreamOutputTypeDef, ConverseStreamResponseTypeDef, CountTokensRequestTypeDef,- DocumentBlockTypeDef, DocumentSourceTypeDef, GuardrailConfigurationTypeDef,- ImageBlockTypeDef, InferenceConfigurationTypeDef, MessageUnionTypeDef, PerformanceConfigurationTypeDef,@@ -81,10 +80,32 @@ ToolSpecificationTypeDef, ToolTypeDef, ToolUseBlockOutputTypeDef,- VideoBlockTypeDef, ) +_SUPPORTED_IMAGE_FORMATS = ('jpeg', 'png', 'gif', 'webp')+_SUPPORTED_VIDEO_FORMATS = ('mkv', 'mov', 'mp4', 'webm', 'flv', 'mpeg', 'mpg', 'wmv', 'three_gp')+_SUPPORTED_DOCUMENT_FORMATS = ('pdf', 'txt', 'csv', 'doc', 'docx', 'xls', 'xlsx', 'html', 'md')+++def _make_image_block(format: str, source: DocumentSourceTypeDef) -> ContentBlockUnionTypeDef:+ if format not in _SUPPORTED_IMAGE_FORMATS:+ raise UserError(f'Unsupported image format: {format}')+ return {'image': {'format': format, 'source': source}}+++def _make_video_block(format: str, source: DocumentSourceTypeDef) -> ContentBlockUnionTypeDef:+ if format not in _SUPPORTED_VIDEO_FORMATS:+ raise UserError(f'Unsupported video format: {format}')+ return {'video': {'format': format, 'source': source}}+++def _make_document_block(name: str, format: str, source: DocumentSourceTypeDef) -> ContentBlockUnionTypeDef:+ if format not in _SUPPORTED_DOCUMENT_FORMATS:+ raise UserError(f'Unsupported document format: {format}')+ return {'document': {'name': name, 'format': format, 'source': source}}++ LatestBedrockModelNames = Literal[ 'amazon.titan-tg1-large', 'amazon.titan-text-lite-v1',@@ -169,6 +190,15 @@ } +def _parse_s3_source(url: str) -> DocumentSourceTypeDef:+ """Parse an S3 URL into a Bedrock DocumentSourceTypeDef."""+ parsed = urlparse(url)+ s3_location: S3LocationTypeDef = {'uri': f'{parsed.scheme}://{parsed.netloc}{parsed.path}'}+ if bucket_owner := parse_qs(parsed.query).get('bucketOwner', [None])[0]:+ s3_location['bucketOwner'] = bucket_owner+ return {'s3Location': s3_location}++ def _insert_cache_point_before_trailing_documents( content: list[Any], *,@@ -834,8 +864,8 @@ def _get_last_user_message_content(messages: list[MessageUnionTypeDef]) -> list[ return None return content - @staticmethod async def _map_user_prompt( # noqa: C901+ self, part: UserPromptPart, document_count: Iterator[int], supports_prompt_caching: bool,@@ -849,62 +879,64 @@ async def _map_user_prompt( # noqa: C901 content.append({'text': item}) elif isinstance(item, BinaryContent): format = item.format+ source: DocumentSourceTypeDef = {'bytes': item.data} if item.is_document:- name = f'Document {next(document_count)}'- assert format in ('pdf', 'txt', 'csv', 'doc', 'docx', 'xls', 'xlsx', 'html', 'md')- content.append({'document': {'name': name, 'format': format, 'source': {'bytes': item.data}}})+ content.append(_make_document_block(f'Document {next(document_count)}', format, source)) elif item.is_image:@@ -666,11 +666,136 @@ class CachePoint: * Anthropic (automatically omitted for Bedrock, as it does not support explicit TTL). See https://docs.claude.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration for more information.""" -MULTI_MODAL_CONTENT_TYPES = (ImageUrl, AudioUrl, DocumentUrl, VideoUrl, BinaryContent)+UploadedFileProviderName: TypeAlias = Literal['anthropic', 'openai', 'google-gla', 'google-vertex', 'bedrock', 'xai']+"""Provider names supported by [`UploadedFile`][pydantic_ai.messages.UploadedFile]."""+++@pydantic_dataclass(repr=False, config=pydantic.ConfigDict(validate_by_name=True))+class UploadedFile:+ """A reference to a file uploaded to a provider's file storage by ID.++ This allows referencing files that have been uploaded via provider-specific file APIs+ rather than providing the file content directly.++ Supported by:++ - [`AnthropicModel`][pydantic_ai.models.anthropic.AnthropicModel]+ - [`OpenAIChatModel`][pydantic_ai.models.openai.OpenAIChatModel]+ - [`OpenAIResponsesModel`][pydantic_ai.models.openai.OpenAIResponsesModel]+ - [`BedrockConverseModel`][pydantic_ai.models.bedrock.BedrockConverseModel]+ - [`GoogleModel`][pydantic_ai.models.google.GoogleModel]+ - [`XaiModel`][pydantic_ai.models.xai.XaiModel]+ """++ file_id: str+ """The provider-specific file identifier."""++ provider_name: UploadedFileProviderName+ """The provider this file belongs to.++ This is required because file IDs are not portable across providers, and using a file ID+ with the wrong provider will always result in an error.++ Tip: Use `model.system` to get the provider name dynamically.+ """++ _: KW_ONLY++ vendor_metadata: dict[str, Any] | None = None+ """Vendor-specific metadata for the file.++ The expected shape of this dictionary depends on the provider:++ Supported by:+ - `GoogleModel`: used as `video_metadata` for video files+ """++ _media_type: Annotated[str | None, pydantic.Field(alias='media_type', default=None, exclude=True)] = field(+ compare=False, default=None+ )++ _identifier: Annotated[str | None, pydantic.Field(alias='identifier', default=None, exclude=True)] = field(+ compare=False, default=None+ )++ kind: Literal['uploaded-file'] = 'uploaded-file'+ """Type identifier, this is available on all parts as a discriminator."""++ # `pydantic_dataclass` replaces `__init__` so this method is never used.+ # The signature is kept so that pyright/IDE hints recognize the `media_type` and `identifier` aliases.+ def __init__(+ self,+ file_id: str,+ provider_name: UploadedFileProviderName,+ *,+ media_type: str | None = None,+ vendor_metadata: dict[str, Any] | None = None,+ identifier: str | None = None,+ kind: Literal['uploaded-file'] = 'uploaded-file',+ # Required for inline-snapshot which expects all dataclass `__init__` methods to take all field names as kwargs.+ _media_type: str | None = None,+ _identifier: str | None = None,+ ) -> None: ... # pragma: no cover++ @pydantic.computed_field+ @property+ def media_type(self) -> str:+ """Return the media type of the file, inferred from `file_id` if not explicitly provided.++ Note: Inference relies on the file extension in `file_id`.+ For opaque file IDs (e.g., `'file-abc123'`), the media type will default to `'application/octet-stream'`.+ Inference relies on Python's `mimetypes` module, whose results may vary across platforms.++ Required by some providers (e.g., Bedrock) for certain file types.+ """+ if self._media_type is not None:+ return self._media_type+ parsed = urlparse(self.file_id)@@ -30,6 +30,8 @@ ThinkingPart, ToolCallPart, ToolReturnPart,+ UploadedFile,+ UploadedFileProviderName, UserContent, UserPromptPart, VideoUrl,@@ -235,16 +237,29 @@ def load_messages(cls, messages: Sequence[UIMessage]) -> list[ModelMessage]: # try: file = BinaryContent.from_data_uri(part.url) except ValueError:- media_type_prefix = part.media_type.split('/', 1)[0]- match media_type_prefix:- case 'image':- file = ImageUrl(url=part.url, media_type=part.media_type)- case 'video':- file = VideoUrl(url=part.url, media_type=part.media_type)- case 'audio':- file = AudioUrl(url=part.url, media_type=part.media_type)- case _:- file = DocumentUrl(url=part.url, media_type=part.media_type)+ # Check provider_metadata for UploadedFile data+ provider_meta = load_provider_metadata(part.provider_metadata)+ uploaded_file_id = provider_meta.get('file_id')+ uploaded_file_provider = provider_meta.get('provider_name')+ if uploaded_file_id and uploaded_file_provider:+ file = UploadedFile(+ file_id=uploaded_file_id,+ provider_name=cast(UploadedFileProviderName, uploaded_file_provider),+ media_type=part.media_type,+ vendor_metadata=provider_meta.get('vendor_metadata'),+ identifier=provider_meta.get('identifier'),+ )+ else:+ media_type_prefix = part.media_type.split('/', 1)[0]+ match media_type_prefix:+ case 'image':+ file = ImageUrl(url=part.url, media_type=part.media_type)+ case 'video':+ file = VideoUrl(url=part.url, media_type=part.media_type)+ case 'audio':+ file = AudioUrl(url=part.url, media_type=part.media_type)+ case _:+ file = DocumentUrl(url=part.url, media_type=part.media_type) user_prompt_content.append(file) else: # pragma: no cover raise ValueError(f'Unsupported user message part type: {type(part)}')@@ -680,6 +695,17 @@ def _convert_user_prompt_part(part: UserPromptPart) -> list[UIMessagePart]: ui_parts.append(FileUIPart(url=item.data_uri, media_type=item.media_type)) elif isinstance(item, ImageUrl | AudioUrl | VideoUrl | DocumentUrl): ui_parts.append(FileUIPart(url=item.url, media_type=item.media_type))+ elif isinstance(item, UploadedFile):+ # Store uploaded file info in provider_metadata for round-trip support+ provider_metadata = dump_provider_metadata(+ file_id=item.file_id,+ provider_name=item.provider_name,+ vendor_metadata=item.vendor_metadata,+ identifier=item._identifier, # pyright: ignore[reportPrivateUsage]+ )+ ui_parts.append(+ FileUIPart(url=item.file_id, media_type=item.media_type, provider_metadata=provider_metadata)+ ) elif isinstance(item, CachePoint): # CachePoint is metadata for prompt caching, skip for UI conversion pass@@ -37,6 +37,7 @@ ModelResponsePart, SystemPromptPart, ToolReturnContent,+ UploadedFile, UserContent, UserPromptPart, )@@ -209,6 +210,27 @@ class UIAdapter(ABC, Generic[RunInputT, MessageT, EventT, AgentDepsT, OutputData `frozenset({True})` or `frozenset({True, 'allow-local'})`. """ + preserve_file_data: bool = False+ """Whether to keep [`UploadedFile`][pydantic_ai.messages.UploadedFile] items from+ client-submitted messages.++ Defaults to `False`. By default, `UploadedFile` items in client-submitted messages are+ dropped with a warning before the messages are passed to the agent, mirroring how+ [`allowed_file_url_schemes`][pydantic_ai.ui.UIAdapter.allowed_file_url_schemes] filters+ [`FileUrl`][pydantic_ai.messages.FileUrl] parts. This applies both to uploaded files in+ user content and to those nested in tool return parts.++ Like a non-HTTP `FileUrl`, an `UploadedFile` references an object that the model provider+ fetches using the server-side IAM role or service account, so a client that can supply+ arbitrary file references can read anything that identity can reach. Uploaded files should+ therefore only be accepted from trusted frontends.++ Set to `True` to keep client-submitted uploaded files after auditing your frontend. Some+ adapters (e.g. AG-UI) additionally use this flag to round-trip agent-generated files and+ uploaded files through their protocol-specific message representation; see the adapter for+ details.+ """+ @classmethod async def from_request( cls,@@ -218,6 +240,7 @@ async def from_request( manage_system_prompt: Literal['server', 'client'] = 'server', allowed_file_url_schemes: frozenset[str] = frozenset({'http', 'https'}), allowed_file_url_force_download: frozenset[ForceDownloadMode] = frozenset(),+ preserve_file_data: bool = False, **kwargs: Any, ) -> Self: """Create an adapter from a request.@@ -232,6 +255,7 @@ async def from_request( manage_system_prompt=manage_system_prompt, allowed_file_url_schemes=allowed_file_url_schemes, allowed_file_url_force_download=allowed_file_url_force_download,+ preserve_file_data=preserve_file_data, **kwargs, ) @@ -323,6 +347,12 @@ def sanitize_messages( and `True` makes the server fetch the file itself — neither is safe to honor from untrusted client input. This applies to file URLs in user content and to those nested in tool return parts.+ - [`UploadedFile`][pydantic_ai.messages.UploadedFile] items unless+ [`preserve_file_data`][pydantic_ai.ui.UIAdapter.preserve_file_data] is `True`.+ Like a non-HTTP `FileUrl`, an `UploadedFile` references an object the model+ provider fetches using the server-side IAM role, so it should only be accepted+ from trusted frontends. This applies both to uploaded files in user content and+ to those nested in tool return parts. - [`ToolCallPart`][pydantic_ai.messages.ToolCallPart] and [`NativeToolCallPart`][pydantic_ai.messages.NativeToolCallPart] entries at the end of the history that don't have a matching entry in@@ -342,6 +372,7 @@ def sanitize_messages( stripped_system_prompt = False disallowed_url_schemes: set[str] = set() reset_force_download_values: set[ForceDownloadMode] = set()+ dropped_uploaded_file_providers: set[str] = set() dangling_tool_call_names: list[str] = [] last_index = len(messages) - 1 @@ -353,6 +384,7 @@ def sanitize_messages( strip_system_prompt=strip_system_prompt, disallowed_schemes=disallowed_url_schemes, reset_force_download_values=reset_force_download_values,+ dropped_uploaded_file_providers=dropped_uploaded_file_providers, ) stripped_system_prompt = stripped_system_prompt or request_stripped_system_prompt if new_request_parts:@@ -366,6 +398,7 @@ def sanitize_messages( dangling_names=dangling_tool_call_names if index == last_index else None, disallowed_schemes=disallowed_url_schemes, reset_force_download_values=reset_force_download_values,+ dropped_uploaded_file_providers=dropped_uploaded_file_providers, ) if new_response_parts: sanitized.append(replace(message, parts=new_response_parts))@@ -408,6 +441,18 @@ def sanitize_messages(@@ -151,6 +151,7 @@ async def from_request( manage_system_prompt: Literal['server', 'client'] = 'server', allowed_file_url_schemes: frozenset[str] = frozenset({'http', 'https'}), allowed_file_url_force_download: frozenset[ForceDownloadMode] = frozenset(),+ preserve_file_data: bool = False, **kwargs: Any, ) -> VercelAIAdapter[AgentDepsT, OutputDataT]: """Extends [`from_request`][pydantic_ai.ui.UIAdapter.from_request] with Vercel AI-specific parameters."""@@ -162,6 +163,7 @@ async def from_request( manage_system_prompt=manage_system_prompt, allowed_file_url_schemes=allowed_file_url_schemes, allowed_file_url_force_download=allowed_file_url_force_download,+ preserve_file_data=preserve_file_data, **kwargs, ) @@ -191,6 +193,7 @@ async def dispatch_request( manage_system_prompt: Literal['server', 'client'] = 'server', allowed_file_url_schemes: frozenset[str] = frozenset({'http', 'https'}), allowed_file_url_force_download: frozenset[ForceDownloadMode] = frozenset(),+ preserve_file_data: bool = False, **kwargs: Any, ) -> Response: """Extends [`dispatch_request`][pydantic_ai.ui.UIAdapter.dispatch_request] with Vercel AI-specific parameters."""@@ -217,6 +220,7 @@ async def dispatch_request( manage_system_prompt=manage_system_prompt, allowed_file_url_schemes=allowed_file_url_schemes, allowed_file_url_force_download=allowed_file_url_force_download,+ preserve_file_data=preserve_file_data, **kwargs, ) @@ -204,7 +204,16 @@ def _user_content_to_input( @dataclass class AGUIAdapter(UIAdapter[RunAgentInput, Message, BaseEvent, AgentDepsT, OutputDataT]):- """UI adapter for the Agent-User Interaction (AG-UI) protocol."""+ """UI adapter for the Agent-User Interaction (AG-UI) protocol.++ When [`preserve_file_data`][pydantic_ai.ui.UIAdapter.preserve_file_data] is `True`,+ agent-generated files and uploaded files are stored as+ [activity messages](https://docs.ag-ui.com/concepts/activities) during `dump_messages`+ and restored during `load_messages`, enabling full round-trip fidelity. When `False`+ (the default), they are dropped. If your AG-UI frontend uses activities, be aware that+ `pydantic_ai_*` activity types are reserved for internal round-trip use and should be+ ignored by frontend activity handlers.+ """ _: KW_ONLY ag_ui_version: str = DEFAULT_AG_UI_VERSION@@ -227,18 +236,6 @@ class AGUIAdapter(UIAdapter[RunAgentInput, Message, BaseEvent, AgentDepsT, Outpu of this setting. """ - preserve_file_data: bool = False- """Whether to preserve agent-generated files and uploaded files in AG-UI message conversion.-- When `True`, agent-generated files and uploaded files are stored as- [activity messages](https://docs.ag-ui.com/concepts/activities) during `dump_messages`- and restored during `load_messages`, enabling full round-trip fidelity.- When `False` (default), they are silently dropped.-- If your AG-UI frontend uses activities, be aware that `pydantic_ai_*` activity types- are reserved for internal round-trip use and should be ignored by frontend activity handlers.- """- @classmethod def build_run_input(cls, body: bytes) -> RunAgentInput: """Build an AG-UI run input object from the request body."""AI-assisted change 0e30f48833e927c00efae6f56e195d6849cd241270f8a0a054b19fb660c6e23b · Fix 2e2c73514bac52b93fdea3620c40a56fc942a339f75e6c2c9f6376e83b98c7d9