Skip to content

FIX: Keep backend media and upload inputs inside managed storage - #2958

Closed
varunj-msft wants to merge 9 commits into
microsoft:mainfrom
varunj-msft:varunj-msft/10765-Improve-Backend-Input-Validation
Closed

varunj-msft wants to merge 9 commits into
microsoft:mainfrom
varunj-msft:varunj-msft/10765-Improve-Backend-Input-Validation

Conversation

@varunj-msft

@varunj-msft varunj-msft commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Description

Status: The separate media-import endpoint and the related simplifications below are implemented locally but are not yet committed or pushed. The GitHub PR head is still 2d4aa44b8. That head already removes the HTTP logging filters, restores exception chaining, and corrects imported format extensions.

The backend accepts input across an API boundary. Callers are trusted operators, but a request must not let the server read arbitrary local files or load caller-selected model code. Operator-selected URLs, target endpoints, and adversarial prompt content remain supported.

Changes already on the PR branch:

  • Managed media references: message, preview, and prepended media paths must resolve inside prompt-memory-entries or seed-prompt-entries under the memory results directory. The media route uses the same containment helper. Data URIs and raw base64 uploads remain supported.
  • Blob references: a URL inside the configured results container and a media folder remains a reference, without its query string. Other Azure Blob URLs cannot remain media-path references because the storage reader would resolve them against the server's container.
  • Ordinary URL references: other http(s) media URLs stay unchanged. url pieces pass through unchanged.
  • Converter constructor files: scalar Path and Path | str parameters accept a data URI or an http(s) URL. Downloads use converter-owned temporary files and cleanup, not durable media storage. Path | str also accepts a managed results blob reference.
  • Target file and code access: HuggingFaceChatTarget cannot be created through the API. Targets that upload local files, such as HTTPXAPITarget, require the server's configured target_upload_directory; callers cannot set that directory. Other target path parameters cannot be set through the API. The target catalog omits types and parameters that creation would reject.
  • Diagnostics: download errors retain the original exception chain. No HTTP logging filter is installed. httpx request logs and httpcore diagnostic logs are not changed by an import.

Local redesign, pending push:

  • One explicit operation: POST /api/media/import takes one url, one media data_type, and an optional mime_type hint. The request model checks the modality once. Its response has exactly value, data_type, and mime_type; it does not echo the source URL or add source metadata.
  • Preview and send only reuse references: import each original or converted URL separately, then use the returned managed value in preview or message requests. Preparing a preview or message never downloads a URL. Reusing the reference keeps the same bytes. The inline import_url option and its special rejection validators are removed; it was not a released API.
  • Modality is not encoding: imported bytes are unchanged. A concrete response MIME type has priority, then the caller MIME hint if the response is missing or generic, then the URL suffix. Known audio mappings preserve formats such as MP3 and Ogg. An unknown format uses .bin, not a modality default such as .wav.
  • Failure cleanup: the import records file ownership before writing. Failed or cancelled writes remove the owned file. A failure before ownership is recorded does not require cleanup storage and retains the original exception.
  • Bounded download: 10 s connect, 30 s read, 60 s total, 100 MiB counted while streaming, and at most 3 redirects. Each redirect must be http(s) without URL credentials. Redirect bodies are not read; Accept-Encoding: identity is used, and API request credentials are not forwarded.
  • Authentication and configuration: the new POST endpoint uses normal API authentication, compatibility-header checks, and runtime admission. allow_media_url_import: false turns off both explicit imports and constructor URL downloads. The existing GET media route remains public and limited to managed media files.

Coordination and intentional limits:

  • Coordinate with FIX: Accept only supported external inputs when building components #2959 for file collections such as GridCompositeConverter.innocuous_images; this PR handles scalar path parameters. Keep its external-input registry checks and this PR's path validation and upload-directory rules when merging. Keep both runtime lifecycle imports when integrating FEAT: Add ScenarioPreset #2931.
  • No URL cache or general network-access policy is added. Operators can select private-network destinations; deployment controls must limit outbound access where required.
  • Each explicit import creates a separate file. Unused imports remain in managed storage. The endpoint is API-only; the frontend has no import control.
  • A concrete response MIME type has priority over the caller hint. This operation does not inspect bytes or convert formats.
  • Callers own optional source information in prompt_metadata; the backend does not inject it.
  • Existing memory rows are not revalidated. Path-versus-base64 detection can still probe a path before containment; on Windows this includes UNC paths. File contents outside managed storage are not accepted.
  • Python-registered HTTPXAPITarget instances retain the existing deprecated unrestricted-upload behavior when no upload directory is set.

Tests and Documentation

Updated tests cover the import response shape, invalid modalities, authentication, compatibility headers, the off switch, unchanged bytes and downstream audio formats, reference reuse, independent original and converted MIME hints, and cleanup after write or filename failures and cancellation. Obsolete inline-import rejection and source-response assertions are removed. The README and configuration examples describe explicit import and its limits.

Local validation on the uncommitted redesign:

  • uv run --no-sync python -m pytest tests\unit\backend\test_media_persistence.py tests\unit\backend\test_media_route.py tests\unit\backend\test_converter_service.py -k "not test_symlink_escaping_results_is_rejected" -q: passed, 339 passed, 5 skipped, 1 deselected. The deselected symlink test requires a Windows privilege not available on this account.
  • uv run --no-sync ruff format pyrit\backend\models\attacks.py pyrit\backend\models\converters.py pyrit\backend\models\media.py pyrit\backend\services\media_persistence.py pyrit\backend\routes\media.py tests\unit\backend\test_media_persistence.py tests\unit\backend\test_converter_service.py tests\unit\backend\test_media_route.py: passed.
  • uv run --no-sync ruff check pyrit\backend\models\attacks.py pyrit\backend\models\converters.py pyrit\backend\models\media.py pyrit\backend\services\media_persistence.py pyrit\backend\routes\media.py tests\unit\backend\test_media_persistence.py tests\unit\backend\test_converter_service.py tests\unit\backend\test_media_route.py: passed.
  • uv run --no-sync ty check pyrit\backend\models\attacks.py pyrit\backend\models\converters.py pyrit\backend\models\media.py pyrit\backend\services\media_persistence.py pyrit\backend\routes\media.py: passed.
  • The full test suite and pre-commit were not run for this local follow-up. uv.lock is unchanged.

No notebooks changed. JupyText was not run.

Media values sent to the backend (message pieces, converter previews, and
prepended conversations) could name any existing local file or any URL, and
converter file parameters could name any Azure Blob URL. The server then read
or forwarded those values. They must now be uploaded content or point into
this server's media storage: the media folders under the memory results path,
or, when results are stored in Azure Blob Storage, those folders in the
configured results container. Blob URLs must start with the results URL and
a media folder, the way the serializer builds them, both as the storage
reader reads them and as HTTP clients decode them. Prepended content is
checked before attack or conversation rows are written.

Converter previews and sent messages now reject url pieces, so the backend no
longer downloads URLs outside its result storage. Stored history can still
hold them.

Targets that read local files or load model code now declare it with
uses_host_resources, and the create-target API rejects those types
(HTTPXAPITarget, HuggingFaceChatTarget). They can still be registered in
Python or with an initializer, where the operator controls their settings.

The media route and media persistence now share one containment helper, and
the backend README documents the checks and the intentional exceptions.
@richlundeen

Copy link
Copy Markdown
Contributor

I agree with keeping backend local paths and host-resource targets behind an operator-controlled boundary. A client-supplied path is interpreted on the server, so it should not allow access to arbitrary host files or model loading.

I do not think we should apply the same blanket rejection to URLs. CoPyRIT users are trusted operators who own the infrastructure, and the backend already permits operator-selected target endpoints and raw HTTP requests. Under that trust model, rejecting media URLs does not remove a meaningful capability; it mainly makes valid media workflows harder.

Could we change the policy to import URL-backed media into managed storage instead?

  1. Fetch the URL once in a central ingestion path, with practical timeout, size, and redirect limits.
  2. Persist the downloaded bytes under the managed prompt-memory-entries or seed-prompt-entries storage.
  3. Pass only that managed reference to converters and targets.

This keeps the useful invariant introduced by this PR—downstream components operate only on managed media—while also giving us stable content for retries and replay, avoiding repeated downloads, and preventing signed URLs from being forwarded to external model providers. A stricter deployment could still disable URL import or configure an allowlist, but I do not think rejection should be the only supported behavior.

Can you update this PR to support controlled URL ingestion while retaining the local-path and host-resource restrictions?

Media values, url pieces, and converter file parameters that are http(s)
URLs are now downloaded once into prompt-memory-entries with a time limit,
a size limit, and a redirect limit, and only the stored copy reaches
converters and targets. Redirects are followed one hop at a time, so every
hop must be a plain http(s) URL and redirect bodies are never read. url
pieces take the media type of their content. Blob URLs inside the results
container stay as references, without their query string.

allow_media_url_import in .pyrit_conf turns URL import off.
@varunj-msft

Copy link
Copy Markdown
Contributor Author

I agree with keeping backend local paths and host-resource targets behind an operator-controlled boundary. A client-supplied path is interpreted on the server, so it should not allow access to arbitrary host files or model loading.

I do not think we should apply the same blanket rejection to URLs. CoPyRIT users are trusted operators who own the infrastructure, and the backend already permits operator-selected target endpoints and raw HTTP requests. Under that trust model, rejecting media URLs does not remove a meaningful capability; it mainly makes valid media workflows harder.

Could we change the policy to import URL-backed media into managed storage instead?

  1. Fetch the URL once in a central ingestion path, with practical timeout, size, and redirect limits.
  2. Persist the downloaded bytes under the managed prompt-memory-entries or seed-prompt-entries storage.
  3. Pass only that managed reference to converters and targets.

This keeps the useful invariant introduced by this PR—downstream components operate only on managed media—while also giving us stable content for retries and replay, avoiding repeated downloads, and preventing signed URLs from being forwarded to external model providers. A stricter deployment could still disable URL import or configure an allowlist, but I do not think rejection should be the only supported behavior.

Can you update this PR to support controlled URL ingestion while retaining the local-path and host-resource restrictions?

Agreed, blanket rejection was too blunt. URLs are now imported instead: media URLs and url pieces are fetched once (60s, 100 MiB, max 3 redirects, each hop checked) into prompt-memory-entries, and converters and targets only see the stored copy, so signed URLs don't get forwarded to providers. url pieces are retyped to whatever media type came back, and converter file params accept URLs the same way. I added allow_media_url_import in .pyrit_conf for deployments that want it off and left an allowlist for a follow-up. Local paths and the HTTPX/HF target restrictions are unchanged.

…rove-Backend-Input-Validation

# Conflicts:
#	pyrit/backend/services/attack_service.py
#	pyrit/backend/services/message_send_service.py
#	tests/unit/backend/test_message_send_service.py
@richlundeen

Copy link
Copy Markdown
Contributor

After another review, I would revise my earlier recommendation: I support the shared ingestion code, bounded downloads, and checks on backend file access, but import should be an explicit capability rather than a requirement for every URL. Our callers are trusted operators, and URL references are useful.

1. Preserve URL references and make type changes explicit

Could we preserve URL-reference behavior and let callers explicitly choose import? url is a supported input type, not necessarily media: AzureBlobStorageTarget accepts the URL string itself. Import also changes types based on headers and extensions; an extensionless image served as application/octet-stream becomes binary_path, which image converters reject. For import, please prefer a caller-declared media type and make unknown types explicit rather than silently choosing binary_path.

Location: pyrit/backend/services/media_persistence.py:315.

2. Use the same source bytes for preview and send

Preview uses the imported copy, but its response still returns the original URL and type. Sending that URL with the preview's converted result downloads the source again. If the URL changes, the stored original can differ from the bytes used for conversion. Could preview return the managed source reference and resolved type for imported inputs, so preview and send use the same copy?

Location: pyrit/backend/services/converter_service.py:239.

3. Keep download failures useful for debugging

Could download errors identify the failure reason without exposing credentials? Connect timeout, read timeout, the total deadline, and other network failures all become "could not be downloaded." The converter and create-attack routes return that message without logging the chained exception. Please include a safe failure category and the timeout limit where applicable, while keeping credentials and query strings redacted.

Location: pyrit/backend/services/media_url_import.py:172.

4. Cover file references inside collections

The constructor-file policy only covers scalar Path / Path | str parameters. GridCompositeConverter.innocuous_images accepts a sequence that passes through the registry unchanged, so its local paths and URLs bypass ingestion. Please apply the same policy to collection elements, or explicitly reject unsupported file collections. If #2959 supplies that rejection, please make it a merge dependency and add coverage for this case.

Location: pyrit/backend/services/converter_service.py:335.

5. Separate file access from local code execution

Could we separate file access from model/code loading rather than reject both through uses_host_resources? Keeping code-loading targets out of API construction makes sense, but HTTPXAPITarget already supports allowed_upload_directory. Could we retain an operator-configured, constrained HTTP upload target instead of banning the whole type? The allowed directory must be controlled by the server, not widened by an API caller.

Location: pyrit/backend/services/target_service.py, the uses_host_resources check.

6. Retain safe source information after import

Import replaces the source URL with a generated storage path, but does not retain source information in the stored message. That makes it harder to trace which input produced an artifact. Could we record the credential-redacted source reference and the declared/resolved type in metadata? Please omit credentials and query strings by default. This belongs in ingestion metadata, not in each converter or target.

Location: pyrit/backend/services/media_persistence.py, persist_message_pieces_async.

Scope notes

These are scope notes, not claims that this PR already adds these features:

  • Caching: Please do not add a shared URL cache in this PR. Reusing the imported reference is enough for preview and send; a cache adds expiration and invalidation rules.
  • Network policy: Please keep host allowlists and private-network restrictions out of this PR. Under our trusted-operator model, those are deployment controls. Keep the download timeout, size, and redirect limits.
  • Constructor storage: Please retain the existing constructor-upload storage and cleanup behavior. Some inputs require local files; this PR does not need to make every font, template, or constructor input a durable result-storage artifact. The URL downloads already reuse the existing constructor-upload directory, so I am not requesting a storage refactor.

Review drafted with assistance from GitHub Copilot.

…ing from file access

- Keep http(s) media values and url pieces as references by default. A caller
  imports a URL by setting import_url on a piece or preview, and the stored copy
  keeps the declared media path type.
- Reject Azure Blob URLs outside the result storage as references, since the
  storage layer would read them from the server's own container.
- Return the stored copy and its source metadata from a preview that imports,
  and record the redacted source URL and content type on imported pieces.
- Name the reason and limit in download errors, and log each failure with the
  classes in its cause chain.
- Replace uses_host_resources with loads_local_code and upload_directory_parameter:
  code-loading targets stay out of the API, HTTPXAPITarget uploads only from the
  operator's target_upload_directory, and target parameters that name server
  paths cannot be set through the API.
…PI can create

Import failures are raised without the httpx exception chain, since httpx messages quote the full URL and background sends log the traceback. A URL httpx cannot parse is a 400 instead of a 500. A blank target_upload_directory is rejected rather than resolving to the working directory, and the target type catalog leaves out types and path parameters that creation always rejects.
httpx logs every request URL at INFO, query string included. While a media URL downloads, a filter on the httpx logger replaces those URLs with the redacted form; other httpx logging is unchanged.
httpcore logs response headers at DEBUG, so a signed redirect Location or Content-Location would reach the log. Those traces are dropped during imports; other httpcore logging is unchanged.
@varunj-msft

Copy link
Copy Markdown
Contributor Author

After another review, I would revise my earlier recommendation: I support the shared ingestion code, bounded downloads, and checks on backend file access, but import should be an explicit capability rather than a requirement for every URL. Our callers are trusted operators, and URL references are useful.

1. Preserve URL references and make type changes explicit

Could we preserve URL-reference behavior and let callers explicitly choose import? url is a supported input type, not necessarily media: AzureBlobStorageTarget accepts the URL string itself. Import also changes types based on headers and extensions; an extensionless image served as application/octet-stream becomes binary_path, which image converters reject. For import, please prefer a caller-declared media type and make unknown types explicit rather than silently choosing binary_path.

Location: pyrit/backend/services/media_persistence.py:315.

2. Use the same source bytes for preview and send

Preview uses the imported copy, but its response still returns the original URL and type. Sending that URL with the preview's converted result downloads the source again. If the URL changes, the stored original can differ from the bytes used for conversion. Could preview return the managed source reference and resolved type for imported inputs, so preview and send use the same copy?

Location: pyrit/backend/services/converter_service.py:239.

3. Keep download failures useful for debugging

Could download errors identify the failure reason without exposing credentials? Connect timeout, read timeout, the total deadline, and other network failures all become "could not be downloaded." The converter and create-attack routes return that message without logging the chained exception. Please include a safe failure category and the timeout limit where applicable, while keeping credentials and query strings redacted.

Location: pyrit/backend/services/media_url_import.py:172.

4. Cover file references inside collections

The constructor-file policy only covers scalar Path / Path | str parameters. GridCompositeConverter.innocuous_images accepts a sequence that passes through the registry unchanged, so its local paths and URLs bypass ingestion. Please apply the same policy to collection elements, or explicitly reject unsupported file collections. If #2959 supplies that rejection, please make it a merge dependency and add coverage for this case.

Location: pyrit/backend/services/converter_service.py:335.

5. Separate file access from local code execution

Could we separate file access from model/code loading rather than reject both through uses_host_resources? Keeping code-loading targets out of API construction makes sense, but HTTPXAPITarget already supports allowed_upload_directory. Could we retain an operator-configured, constrained HTTP upload target instead of banning the whole type? The allowed directory must be controlled by the server, not widened by an API caller.

Location: pyrit/backend/services/target_service.py, the uses_host_resources check.

6. Retain safe source information after import

Import replaces the source URL with a generated storage path, but does not retain source information in the stored message. That makes it harder to trace which input produced an artifact. Could we record the credential-redacted source reference and the declared/resolved type in metadata? Please omit credentials and query strings by default. This belongs in ingestion metadata, not in each converter or target.

Location: pyrit/backend/services/media_persistence.py, persist_message_pieces_async.

Scope notes

These are scope notes, not claims that this PR already adds these features:

  • Caching: Please do not add a shared URL cache in this PR. Reusing the imported reference is enough for preview and send; a cache adds expiration and invalidation rules.
  • Network policy: Please keep host allowlists and private-network restrictions out of this PR. Under our trusted-operator model, those are deployment controls. Keep the download timeout, size, and redirect limits.
  • Constructor storage: Please retain the existing constructor-upload storage and cleanup behavior. Some inputs require local files; this PR does not need to make every font, template, or constructor input a durable result-storage artifact. The URL downloads already reuse the existing constructor-upload directory, so I am not requesting a storage refactor.

Review drafted with assistance from GitHub Copilot.

Thanks for taking another look. Going through them in order:

  1. URLs now stay as references unless the caller opts in with import_url on the piece or the preview request, and url pieces pass through untouched. An imported copy keeps the type the caller declared, so an octet-stream image stays image_path. A url piece can't be imported since it doesn't say what kind of media it is. The one exception is Azure Blob URLs outside our managed media storage. Those get rejected as references because the storage layer would read them from our own container, so they need import_url. Converter file params still download URLs since the constructor needs a local file, except Path | str params, which keep our own blob URLs as references.

  2. When a preview imports, it now returns the stored copy along with its type and the source metadata, so sending those reuses the same bytes instead of downloading again. The GUI doesn't import yet, so its previews work the same as before.

  3. Failures now say what went wrong and the limit when there is one, like "connecting timed out after 10 seconds" or "returned HTTP 404". HTTP and network failures get logged with the redacted URL and the exception class names. I also stopped chaining the httpx exception since its message has the full URL. While an import is running, httpx's request log lines are redacted and httpcore's header traces are skipped, so the query string stays out of the logs.

  4. FIX: Accept only supported external inputs when building components #2959 covers this one. It rejects GridCompositeConverter.innocuous_images at the API boundary before anything is read or registered, so the request gets a 400, and there are tests for both a local path and a URL. That means FIX: Accept only supported external inputs when building components #2959 needs to merge first, which I noted in the description.

  5. These are split now. HuggingFaceChatTarget is flagged as loading local code and still can't be created through the API. HTTPXAPITarget isn't banned anymore: it can be created when target_upload_directory is set in .pyrit_conf, and the server passes that directory in so callers can't override it. Target params that point at server paths can't be set through the API either (that also catches GitHubCopilotTarget.working_directory), and /api/targets/types no longer lists anything creation would reject.

  6. Imported pieces now record media_source_url with credentials and the query string stripped, plus media_source_content_type when we know it. The declared type stays on the piece. Converted values get the same keys with a converted_ prefix.

On the scope notes, agreed on all three. No cache, no host or private-network rules, and constructor uploads keep the storage and cleanup they have today.

Resolve imported formats from response and caller MIME types or URL suffixes without changing bytes or defaulting unknown media to WAV. Remove global HTTP logging filters and preserve download exception causes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@richlundeen

Copy link
Copy Markdown
Contributor

I recommend closing this PR and breaking up to smaller. I made #3061 as an example. Chatted offline but let's chat more later!

@varunj-msft

Copy link
Copy Markdown
Contributor Author

I recommend closing this PR and breaking up to smaller. I made #3061 as an example. Chatted offline but let's chat more later!

Thanks Rich, closing this in favor of #3061, which covers the main piece here. I'll bring the other parts back one at a time as their own PRs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants