Skip to content

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

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

varunj-msft wants to merge 8 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

The backend is the part of PyRIT that takes input across a trust boundary. Some media and file inputs were not checked before use:

  • Media values in sent messages, converter previews, and prepended conversations could name any existing local file, and the server read it. Prepended content skipped media handling entirely.
  • Converter Path | str parameters accepted any Azure Blob URL.
  • HTTPXAPITarget (uploads files named in params or message text) and HuggingFaceChatTarget (model_path, trust_remote_code) could be created through the API with caller-chosen files and code.

Changes:

  • Managed storage only: one containment helper in services/media_persistence.py, shared with the media route. Local media must resolve inside the prompt-memory-entries / seed-prompt-entries folders under the memory results path. When results are in Azure Blob Storage, a media URL under the configured results URL and a media folder is kept as a reference, without its query string. Other Azure Blob URLs are rejected as media references, because the storage layer would read them from this server's own container; those need import_url. Data URIs and raw base64 uploads are unchanged.
  • URL references stay references (per review): other http(s) media URLs are kept as given, as on main, and url pieces pass through untouched.
  • Explicit import: import_url: true on a message piece or a converter preview request downloads the URL once with services/media_url_import.py and stores it under prompt-memory-entries with the declared type (image_path, audio_path, video_path, or binary_path). Content from a different media family than the declared type is rejected; generic content types keep the declared type. A url piece can't be imported, since it has no media type. Limits:
    • 10 s to connect, 30 s per read, 60 s in total;
    • 100 MiB, counted while streaming;
    • at most 3 redirects, followed one hop at a time; every hop must be http(s) without credentials, and redirect bodies are never read;
    • Accept-Encoding: identity, and no request credentials forwarded.
  • Preview and send use the same bytes: a preview that imports returns the stored original_value, its type, and the source metadata. Sending those reuses the stored copy without downloading again.
  • Source metadata: imported pieces record media_source_url (no credentials, query string, or fragment) and, when known, media_source_content_type; converted values use converted_media_source_*. This is recorded at ingestion, including attack creation.
  • Failures: errors name the reason and the limit, e.g. "connecting timed out after 10 seconds" or "returned HTTP 404". HTTP and network failures are logged at WARNING with the redacted URL and the exception classes. The httpx exception is not chained onto the raised error (its message has the full URL, and background sends log tracebacks). While an import runs, httpx's INFO request lines are redacted and httpcore's DEBUG protocol traces, which include response headers such as a signed redirect Location, are dropped; other HTTP client logging is unchanged. A URL httpx can't parse is a 400.
  • Converter file parameters: Path and Path | str parameters take an upload (data URI) or an http(s) URL. A URL is downloaded into the converter's upload directory and removed with the converter. Path | str also accepts a managed results blob URL as a reference.
  • Off switch: allow_media_url_import: false in .pyrit_conf (default true) turns imports off.
  • Prepended content: goes through the same persistence before any attack or conversation row is written. create_attack maps "not found" to 404 and other validation errors to 400 (same as add_message).
  • Code loading and file access are separate:
    • PromptTarget.loads_local_code marks targets that load model code. HuggingFaceChatTarget sets it and is never created through the API.
    • PromptTarget.upload_directory_parameter names the parameter for a target's upload directory. HTTPXAPITarget sets it (allowed_upload_directory) and can be created through the API only when target_upload_directory is set in .pyrit_conf. The server passes that directory to the target, and callers can't set it. A blank value is rejected when the config loads.
    • Target parameters typed Path or Path | str can't be set through the API. This also covers GitHubCopilotTarget.working_directory.
    • /api/targets/types leaves out the target types and parameters that creation would reject. Registry metadata keeps them.
  • Docs: pyrit/backend/README.md "Input Validation" covers what is checked and the intentional exceptions (operator-chosen target destinations and media URLs, unfiltered prompt content, any payload file type, public /api/media, admin-only custom initializers). .pyrit_conf_example documents allow_media_url_import and target_upload_directory.

Coordination and known limits:

  • Merge after FIX: Accept only supported external inputs when building components #2959. This PR handles scalar path parameters only; FIX: Accept only supported external inputs when building components #2959 rejects file collections such as GridCompositeConverter.innocuous_images. When resolving target_service.py and converter_service.py, keep both sides: FIX: Accept only supported external inputs when building components #2959's create_instance_from_external_input and external catalog filter, and this PR's _reject_server_resources, _can_create_through_api, path-parameter filtering, and upload directory injection.
  • FEAT: Add ScenarioPreset #2931 (open) adds an import next to this PR's in runtime_lifecycle.py; keep both.
  • Plain URL references behave as on main. A target that reads *_path values from disk can't read an http(s) reference; set import_url for those.
  • Path probing: the path-vs-base64 check calls Path.is_file() before containment, so the error tells an existing outside file apart from a missing one. File contents are never returned. This matches the shared-team trust model from FIX: Sanitize externally visible API errors #2237.
  • Stored rows: rows already in memory are not re-validated; this PR checks input at the API boundary.
  • Private networks: media URLs aren't restricted by host, like target endpoints. The README says to limit outbound access in the deployment.
  • Windows: a UNC value such as \\host\share\x.png is rejected, but only after Path.is_file() looks it up. Rejecting outside paths before any filesystem call is a follow-up; it has to keep accepting raw base64 that looks like a path (JPEG base64 starts with /9j/).
  • Operator-registered HTTPXAPITarget: instances registered in Python still upload files named in message text when allowed_upload_directory is unset. That behavior is deprecated, with removal planned for 1.3.0.

Tests and Documentation

  • Tests:
    • test_media_url_import.py: size, redirect, and credential limits; failure reasons and limits; redacted logging; no chained httpx exception; redacted httpx request lines and dropped httpcore traces during imports only; unparseable URLs; the off switch.
    • test_media_persistence.py: containment, traversal, symlinks, remote results root, encoded separators checked against the real storage reader; references kept by default; import keeping the declared type; blob reference rules; source metadata.
    • test_message_send_service.py: a failed import in a background send (count 1 and 2, real SQLite) logs no query string at INFO.
    • test_converter_service.py (preview import returns the stored copy and metadata), test_target_service.py (code-loading and upload targets, server path parameters, configured upload directory, catalog), test_attack_service.py (source metadata on attack creation), test_api_routes.py, test_conversation_editor.py, test_runtime_lifecycle.py, tests/unit/setup/test_configuration_loader.py (blank and non-string target_upload_directory).
  • python -m pytest tests/unit/backend tests/unit/setup tests/unit/prompt_target tests/unit/converter -n 4: 6379 passed, 162 skipped. Four MCP/tool test modules fail to import in this environment, on main too.
  • ruff format / ruff check: passed. ty check pyrit/: same diagnostics as main.
  • Upstream CI on 805d843a9: all checks passed, including unit tests on ubuntu, windows, and macOS across Python 3.11–3.14.
  • Live backend (pyrit_backend with a local config and .env) against a local listener:
    • 38/38 round-three checks: references and url pieces kept, explicit import with the declared type and source metadata, preview-then-send reusing one download, failure reasons without query strings, converter file parameters from URLs, Hugging Face and caller-chosen directories rejected, HTTPX uploading only from the configured directory.
    • 7/7 follow-up checks: the target catalog leaves out Hugging Face and the path parameters; an unparseable URL is a 400; failed imports in background sends (count 1 and 2) leave no query string in the backend log.
    • A blank target_upload_directory fails at startup with a clear error.
    • With DEBUG logging on and a real local server, imports log no query string: not in httpx request lines (success, redirect hop, 404) and not through a signed redirect Location or Content-Location header. Requests outside an import log as before.
  • No notebooks changed (JupyText not needed).

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.

This branch has not been deployed

No deployments
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