Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions doc/code/executor/5_workflow.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -802,7 +802,7 @@
"source": [
"import pathlib\n",
"\n",
"from pyrit.common.path import CONVERTER_SEED_PROMPT_PATH\n",
"from pyrit.common.path import CONVERTER_SEED_PROMPT_PATH, DB_DATA_PATH\n",
"from pyrit.converter import PDFConverter\n",
"from pyrit.executor.core import StrategyConverterConfig\n",
"from pyrit.executor.workflow import XPIATestWorkflow\n",
Expand Down Expand Up @@ -865,7 +865,12 @@
" injection_items=injection_items, # Inject hidden text\n",
")\n",
"\n",
"upload_target = HTTPXAPITarget(http_url=f\"http://localhost:8000/upload/\", method=\"POST\", timeout=180)\n",
"upload_target = HTTPXAPITarget(\n",
" http_url=\"http://localhost:8000/upload/\",\n",
" method=\"POST\",\n",
" allowed_upload_directory=DB_DATA_PATH,\n",
" timeout=180,\n",
")\n",
"\n",
"http_api_processing_target = HTTPXAPITarget(\n",
" http_url=f\"http://localhost:8000/search_candidates/\", method=\"POST\", timeout=180\n",
Expand Down
9 changes: 7 additions & 2 deletions doc/code/executor/5_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ async def processing_callback() -> str:
# %%
import pathlib

from pyrit.common.path import CONVERTER_SEED_PROMPT_PATH
from pyrit.common.path import CONVERTER_SEED_PROMPT_PATH, DB_DATA_PATH
from pyrit.converter import PDFConverter
from pyrit.executor.core import StrategyConverterConfig
from pyrit.executor.workflow import XPIATestWorkflow
Expand Down Expand Up @@ -254,7 +254,12 @@ async def processing_callback() -> str:
injection_items=injection_items, # Inject hidden text
)

upload_target = HTTPXAPITarget(http_url=f"http://localhost:8000/upload/", method="POST", timeout=180)
upload_target = HTTPXAPITarget(
http_url="http://localhost:8000/upload/",
method="POST",
allowed_upload_directory=DB_DATA_PATH,
timeout=180,
)

http_api_processing_target = HTTPXAPITarget(
http_url=f"http://localhost:8000/search_candidates/", method="POST", timeout=180
Expand Down
44 changes: 42 additions & 2 deletions pyrit/prompt_target/http_target/http_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import re
from collections.abc import Callable
from typing import Any
from urllib.parse import urlsplit

import httpx

Expand Down Expand Up @@ -42,6 +43,7 @@ def __init__(
max_requests_per_minute: int | None = None,
client: httpx.AsyncClient | None = None,
model_name: str = "",
follow_redirects: bool = True,
custom_configuration: TargetConfiguration | None = None,
**httpx_client_kwargs: Any,
) -> None:
Expand All @@ -58,6 +60,8 @@ def __init__(
max_requests_per_minute (int, Optional): Maximum number of requests per minute.
client (httpx.AsyncClient, Optional): Pre-configured httpx client.
model_name (str): The model name. Defaults to empty string.
follow_redirects (bool): Whether to follow HTTP redirects. Defaults to True for backward compatibility;
set to False when redirects are unnecessary or the destination must remain fixed.
custom_configuration (TargetConfiguration, Optional): Override the default configuration for
this target instance. Defaults to None.
**httpx_client_kwargs: Additional keyword arguments for httpx.AsyncClient.
Expand All @@ -72,6 +76,7 @@ def __init__(
# Parse the URL early to use as endpoint identifier
# This will fail early if the http_request is malformed
_, _, endpoint, _, _ = self.parse_raw_http_request(http_request)
self._destination_origin = self._get_destination_origin(endpoint)

super().__init__(
max_requests_per_minute=max_requests_per_minute,
Expand All @@ -82,6 +87,7 @@ def __init__(
self.http_request = http_request
self.callback_function = callback_function
self.prompt_regex_string = prompt_regex_string
self.follow_redirects = follow_redirects
self.httpx_client_kwargs = httpx_client_kwargs or {}

if client and httpx_client_kwargs:
Expand All @@ -99,6 +105,7 @@ def _build_identifier(self) -> ComponentIdentifier:
"use_tls": self.use_tls,
"prompt_regex_string": self.prompt_regex_string,
"callback_function": getattr(self.callback_function, "__name__", None),
"follow_redirects": self.follow_redirects,
},
)

Expand All @@ -110,6 +117,7 @@ def with_client(
prompt_regex_string: str = "{PROMPT}",
callback_function: Callable[..., Any] | None = None,
max_requests_per_minute: int | None = None,
follow_redirects: bool = True,
) -> "HTTPTarget":
"""
Alternative constructor that accepts a pre-configured httpx client.
Expand All @@ -120,6 +128,8 @@ def with_client(
prompt_regex_string: the placeholder for the prompt
callback_function: function to parse HTTP response
max_requests_per_minute: Optional rate limiting
follow_redirects: Whether to follow HTTP redirects. Defaults to True for backward compatibility; set to
False when redirects are unnecessary or the destination must remain fixed.

Returns:
HTTPTarget: an instance of HTTPTarget
Expand All @@ -130,6 +140,7 @@ def with_client(
callback_function=callback_function,
max_requests_per_minute=max_requests_per_minute,
client=client,
follow_redirects=follow_redirects,
)

def _inject_prompt_into_request(self, request: MessagePiece) -> str:
Expand All @@ -142,8 +153,12 @@ def _inject_prompt_into_request(self, request: MessagePiece) -> str:

Returns:
str: the http request with the prompt added in

Raises:
ValueError: If a multiline prompt would be substituted into the request line or headers.
"""
re_pattern = re.compile(self.prompt_regex_string)
self._validate_prompt_for_template_context(prompt=request.converted_value, pattern=re_pattern)
if re.search(self.prompt_regex_string, self.http_request):
http_request_w_prompt = re_pattern.sub(lambda m: request.converted_value, self.http_request)
else:
Expand All @@ -169,6 +184,7 @@ async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Me
http_request_w_prompt = self._inject_prompt_into_request(request)

header_dict, http_body, url, http_method, http_version = self.parse_raw_http_request(http_request_w_prompt)
self._validate_destination(url)

if "Content-Length" in header_dict:
header_dict["Content-Length"] = str(len(http_body))
Expand All @@ -191,15 +207,15 @@ async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Me
url=url,
headers=header_dict,
data=http_body,
follow_redirects=True,
follow_redirects=self.follow_redirects,
)
else:
response = await client.request(
method=http_method,
url=url,
headers=header_dict,
content=http_body,
follow_redirects=True,
follow_redirects=self.follow_redirects,
)

response_content = response.content
Expand Down Expand Up @@ -296,3 +312,27 @@ def _infer_full_url_from_host(

host = headers_dict["host"]
return f"{http_protocol}{host}{path}"

def _validate_destination(self, url: str) -> None:
destination_origin = self._get_destination_origin(url)
if destination_origin != self._destination_origin:
raise ValueError("Prompt substitution cannot change the configured HTTP destination.")

def _validate_prompt_for_template_context(self, *, prompt: str, pattern: re.Pattern[str]) -> None:
if "\r" not in prompt and "\n" not in prompt:
return

separator = re.search(r"\r?\n\r?\n", self.http_request)
header_end = separator.start() if separator else len(self.http_request)

if any(match.start() < header_end for match in pattern.finditer(self.http_request)):
raise ValueError("Prompts substituted into the HTTP request line or headers cannot contain CR or LF.")

@staticmethod
def _get_destination_origin(url: str) -> tuple[str, str | None, int | None]:
parsed_url = urlsplit(url)
try:
port = parsed_url.port
except ValueError as exc:
raise ValueError(f"Invalid port in HTTP destination: {url}") from exc
return parsed_url.scheme.lower(), parsed_url.hostname, port
88 changes: 70 additions & 18 deletions pyrit/prompt_target/http_target/httpx_api_target.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

import asyncio
import logging
import mimetypes
from collections.abc import Callable
Expand All @@ -10,6 +11,7 @@
import aiofiles
import httpx

from pyrit.common.deprecation import print_deprecation_message
from pyrit.models import (
Message,
MessagePiece,
Expand All @@ -33,6 +35,7 @@ class HTTPXAPITarget(HTTPTarget):
it's a local file path generated by a Converter (like PDFConverter).
"""

_PATH_TYPES: frozenset[str] = frozenset({"image_path", "audio_path", "video_path", "binary_path"})
_DEFAULT_CONFIGURATION: TargetConfiguration = TargetConfiguration(
capabilities=TargetCapabilities(
supports_multi_turn=True,
Expand All @@ -54,11 +57,13 @@ def __init__(
http_url: str,
method: Literal["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"] = "POST",
file_path: str | None = None,
allowed_upload_directory: str | Path | None = None,
json_data: dict[str, Any] | None = None,
form_data: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
http2: bool | None = None,
follow_redirects: bool = True,
callback_function: Callable[..., Any] | None = None,
max_requests_per_minute: int | None = None,
custom_configuration: TargetConfiguration | None = None,
Expand All @@ -72,11 +77,15 @@ def __init__(
method (str): The HTTP method to use (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS). Defaults to "POST".
file_path (str, Optional): Path to a file to upload. If not provided, we attempt to pull it from the
prompt's `converted_value`.
allowed_upload_directory (str | Path | None): Directory containing files this target may upload.
Required when uploading files. Defaults to None.
json_data (dict, Optional): JSON data to send in the request body (for POST/PUT/PATCH).
form_data (dict, Optional): Form data to send in the request body (for POST/PUT/PATCH).
params (dict, Optional): Query parameters to include in the request URL (for GET/HEAD).
headers (dict, Optional): Headers to include in the request.
http2 (bool, Optional): Whether to use HTTP/2. If None, defaults to False.
follow_redirects (bool): Whether to follow HTTP redirects. Defaults to True for backward compatibility;
set to False when redirects are unnecessary or the destination must remain fixed.
callback_function (Callable, Optional): Function to parse the HTTP response.
max_requests_per_minute (int, Optional): Maximum number of requests per minute.
custom_configuration (TargetConfiguration, Optional): Override the default configuration for this target
Expand All @@ -87,11 +96,13 @@ def __init__(
Raises:
ValueError: If the HTTP method is invalid.
ValueError: If file uploads are attempted with an HTTP method that does not support them.
ValueError: If the allowed upload directory does not exist or is not a directory.
"""
super().__init__(
http_request="",
prompt_regex_string="",
use_tls=True,
follow_redirects=follow_redirects,
callback_function=callback_function,
max_requests_per_minute=max_requests_per_minute,
custom_configuration=custom_configuration,
Expand All @@ -101,6 +112,7 @@ def __init__(
self.http_url = http_url
self.method = method
self.file_path = file_path
self.allowed_upload_directory = self._resolve_allowed_upload_directory(allowed_upload_directory)
self.json_data = json_data
self.form_data = form_data
self.params = params
Expand All @@ -111,10 +123,6 @@ def __init__(
if self.method not in {"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}:
raise ValueError(f"Invalid HTTP method: {self.method}")

# Validate file uploads (only `POST` and `PUT` allow file uploads)
if self.file_path and self.method not in {"POST", "PUT"}:
raise ValueError(f"File uploads are not allowed with HTTP method: {self.method}")

@limit_requests_per_minute
async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Message]) -> list[Message]:
"""
Expand All @@ -135,15 +143,9 @@ async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Me
"""
message = normalized_conversation[-1]
message_piece: MessagePiece = message.message_pieces[0]

# If user didn't set file_path, see if the PDF path is in converted_value
if not self.file_path:
possible_path = message_piece.converted_value
if isinstance(possible_path, str) and Path(possible_path).exists():
logger.info(f"HTTPXApiTarget: auto-using file_path from {possible_path}")
self.file_path = possible_path
elif not Path(self.file_path).exists():
raise FileNotFoundError(f"File not found: {self.file_path}")
upload_path = await self._get_upload_path_async(message_piece=message_piece)
if upload_path and self.method not in {"POST", "PUT"}:
raise ValueError(f"File uploads are not allowed with HTTP method: {self.method}")

if not self.http_url:
raise ValueError("No `http_url` provided for HTTPXApiTarget.")
Expand All @@ -152,12 +154,12 @@ async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Me

async with httpx.AsyncClient(http2=http2_version, **self.httpx_client_kwargs) as client:
try:
if self.file_path and Path(self.file_path).exists():
if upload_path:
# Handle file upload (only for POST & PUT)
filename = Path(self.file_path).name
filename = upload_path.name
mime_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"

async with aiofiles.open(self.file_path, "rb") as fp:
async with aiofiles.open(upload_path, "rb") as fp:
file_bytes = await fp.read()

files = {"file": (filename, file_bytes, mime_type)}
Expand All @@ -170,7 +172,7 @@ async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Me
headers=self.headers,
params=self.params,
files=files,
follow_redirects=True,
follow_redirects=self.follow_redirects,
)
else:
# No file upload, handle based on HTTP method
Expand All @@ -182,7 +184,7 @@ async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Me
params=self.params,
json=self.json_data if self.method in {"POST", "PUT", "PATCH"} else None,
data=self.form_data if self.method in {"POST", "PUT", "PATCH"} else None,
follow_redirects=True,
follow_redirects=self.follow_redirects,
)

except httpx.TimeoutException:
Expand All @@ -207,3 +209,53 @@ async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Me
)

return [response_entry]

async def _get_upload_path_async(self, *, message_piece: MessagePiece) -> Path | None:
if self.file_path:
candidate = Path(self.file_path)
elif message_piece.converted_value_data_type in self._PATH_TYPES:
candidate = Path(message_piece.converted_value)
elif await asyncio.to_thread(Path(message_piece.converted_value).is_file):
candidate = Path(message_piece.converted_value)
print_deprecation_message(
old_item="HTTPXAPITarget implicit text-path uploads",
new_item="a path-typed MessagePiece or explicit file_path",
removed_in="1.3.0",
)
else:
return None

if self.allowed_upload_directory is None:
print_deprecation_message(
old_item="HTTPXAPITarget file uploads without allowed_upload_directory",
new_item="HTTPXAPITarget(..., allowed_upload_directory=...)",
removed_in="1.3.0",
)
candidate = await asyncio.to_thread(candidate.resolve)
else:
candidate = await asyncio.to_thread(
self._validate_upload_path,
path=candidate,
allowed_directory=self.allowed_upload_directory,
)
if not await asyncio.to_thread(candidate.is_file):
raise FileNotFoundError(f"File not found: {candidate}")
return candidate

def _validate_upload_path(self, *, path: Path, allowed_directory: Path) -> Path:
resolved_path = path.resolve()
try:
resolved_path.relative_to(allowed_directory)
except ValueError as exc:
raise ValueError(f"File upload path is outside the allowed upload directory: {resolved_path}") from exc
return resolved_path

@staticmethod
def _resolve_allowed_upload_directory(allowed_directory: str | Path | None) -> Path | None:
if allowed_directory is None:
return None

resolved_directory = Path(allowed_directory).resolve()
if not resolved_directory.is_dir():
raise ValueError(f"Allowed upload directory does not exist or is not a directory: {allowed_directory}")
return resolved_directory
3 changes: 2 additions & 1 deletion tests/integration/ai_recruiter/test_ai_recruiter.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import pytest
import requests

from pyrit.common.path import CONVERTER_SEED_PROMPT_PATH, HOME_PATH
from pyrit.common.path import CONVERTER_SEED_PROMPT_PATH, DB_DATA_PATH, HOME_PATH
from pyrit.converter import PDFConverter
from pyrit.exceptions import PyritException
from pyrit.executor.core import StrategyConverterConfig
Expand Down Expand Up @@ -193,6 +193,7 @@ async def test_ai_recruiter_workflow():
upload_target = HTTPXAPITarget(
http_url="http://localhost:8000/upload/",
method="POST",
allowed_upload_directory=DB_DATA_PATH,
timeout=180,
)

Expand Down
Loading
Loading