Skip to content

Commit 0ce08d8

Browse files
Merge branch 'main' into hannahwestra25-adversarial-benchmark-system-prompt
2 parents dc67c7f + f10086d commit 0ce08d8

17 files changed

Lines changed: 549 additions & 125 deletions

doc/code/executor/5_workflow.ipynb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -802,7 +802,7 @@
802802
"source": [
803803
"import pathlib\n",
804804
"\n",
805-
"from pyrit.common.path import CONVERTER_SEED_PROMPT_PATH\n",
805+
"from pyrit.common.path import CONVERTER_SEED_PROMPT_PATH, DB_DATA_PATH\n",
806806
"from pyrit.converter import PDFConverter\n",
807807
"from pyrit.executor.core import StrategyConverterConfig\n",
808808
"from pyrit.executor.workflow import XPIATestWorkflow\n",
@@ -865,7 +865,12 @@
865865
" injection_items=injection_items, # Inject hidden text\n",
866866
")\n",
867867
"\n",
868-
"upload_target = HTTPXAPITarget(http_url=f\"http://localhost:8000/upload/\", method=\"POST\", timeout=180)\n",
868+
"upload_target = HTTPXAPITarget(\n",
869+
" http_url=\"http://localhost:8000/upload/\",\n",
870+
" method=\"POST\",\n",
871+
" allowed_upload_directory=DB_DATA_PATH,\n",
872+
" timeout=180,\n",
873+
")\n",
869874
"\n",
870875
"http_api_processing_target = HTTPXAPITarget(\n",
871876
" http_url=f\"http://localhost:8000/search_candidates/\", method=\"POST\", timeout=180\n",

doc/code/executor/5_workflow.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ async def processing_callback() -> str:
191191
# %%
192192
import pathlib
193193

194-
from pyrit.common.path import CONVERTER_SEED_PROMPT_PATH
194+
from pyrit.common.path import CONVERTER_SEED_PROMPT_PATH, DB_DATA_PATH
195195
from pyrit.converter import PDFConverter
196196
from pyrit.executor.core import StrategyConverterConfig
197197
from pyrit.executor.workflow import XPIATestWorkflow
@@ -254,7 +254,12 @@ async def processing_callback() -> str:
254254
injection_items=injection_items, # Inject hidden text
255255
)
256256

257-
upload_target = HTTPXAPITarget(http_url=f"http://localhost:8000/upload/", method="POST", timeout=180)
257+
upload_target = HTTPXAPITarget(
258+
http_url="http://localhost:8000/upload/",
259+
method="POST",
260+
allowed_upload_directory=DB_DATA_PATH,
261+
timeout=180,
262+
)
258263

259264
http_api_processing_target = HTTPXAPITarget(
260265
http_url=f"http://localhost:8000/search_candidates/", method="POST", timeout=180

frontend/src/components/Initializers/AdditionalInitializers.styles.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,14 @@ export const useAdditionalInitializersStyles = makeStyles({
8585
flexDirection: 'column',
8686
gap: tokens.spacingVerticalXXS,
8787
},
88+
srOnly: {
89+
position: 'absolute',
90+
width: '1px',
91+
height: '1px',
92+
padding: '0',
93+
margin: '-1px',
94+
overflow: 'hidden',
95+
clip: 'rect(0,0,0,0)',
96+
whiteSpace: 'nowrap',
97+
},
8898
})

frontend/src/components/Initializers/InitializerParametersDialog.test.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,18 @@ describe('InitializerParametersDialog', () => {
8484
expect(screen.getByTestId('param-label')).toHaveAttribute('type', 'text')
8585
})
8686

87+
it('should give each multiselect checkbox its own accessible name', () => {
88+
render(
89+
<TestWrapper>
90+
<InitializerParametersDialog {...baseProps} initializer={allKindsInitializer} />
91+
</TestWrapper>,
92+
)
93+
94+
expect(screen.getByRole('group', { name: 'tags' })).toBeInTheDocument()
95+
expect(screen.getByRole('checkbox', { name: 'a' })).toBeInTheDocument()
96+
expect(screen.getByRole('checkbox', { name: 'b' })).toBeInTheDocument()
97+
})
98+
8799
it('shows a no-parameters message and submits null for a parameterless initializer', async () => {
88100
const user = userEvent.setup()
89101
const onSubmit = jest.fn().mockResolvedValue(undefined)

frontend/src/components/Initializers/InitializerParametersDialog.tsx

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -187,23 +187,28 @@ function ParameterField({ parameter, value, disabled, onChange }: ParameterField
187187
const selected = Array.isArray(value) ? value : []
188188
return (
189189
<Field label={label} hint={parameter.description ?? undefined}>
190-
<div className={styles.checkboxGroup} role="group" aria-label={parameter.name}>
191-
{(parameter.choices ?? []).map((choice) => (
192-
<Checkbox
193-
key={choice}
194-
id={`param-${parameter.name}-${choice}`}
195-
label={choice}
196-
checked={selected.includes(choice)}
197-
disabled={disabled}
198-
onChange={(_, data) => {
199-
const next = data.checked
200-
? [...selected, choice]
201-
: selected.filter((entry) => entry !== choice)
202-
onChange(parameter.name, next)
203-
}}
204-
data-testid={`param-${parameter.name}-${choice}`}
205-
/>
206-
))}
190+
<div className={styles.checkboxGroup} role="group" aria-labelledby={`param-${parameter.name}-group-label`}>
191+
<span id={`param-${parameter.name}-group-label`} className={styles.srOnly}>{label}</span>
192+
{(parameter.choices ?? []).map((choice) => {
193+
const choiceLabelId = `param-${encodeURIComponent(parameter.name)}-${encodeURIComponent(choice)}-label`
194+
return (
195+
<Checkbox
196+
key={choice}
197+
id={`param-${parameter.name}-${choice}`}
198+
aria-labelledby={choiceLabelId}
199+
label={{ children: choice, id: choiceLabelId }}
200+
checked={selected.includes(choice)}
201+
disabled={disabled}
202+
onChange={(_, data) => {
203+
const next = data.checked
204+
? [...selected, choice]
205+
: selected.filter((entry) => entry !== choice)
206+
onChange(parameter.name, next)
207+
}}
208+
data-testid={`param-${parameter.name}-${choice}`}
209+
/>
210+
)
211+
})}
207212
</div>
208213
</Field>
209214
)

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ litellm = [
140140
# platforms cannot be pinned back selectively. 1.91.x is the last line with a
141141
# universal wheel that installs everywhere. Revisit once litellm ships macOS
142142
# and musllinux wheels.
143-
"litellm>=1.83.0,<1.92.0",
143+
"litellm>=1.84.0,<1.92.0",
144144
]
145145

146146
# all includes all functional dependencies excluding the ones from the "dev" dependency group
@@ -151,7 +151,7 @@ all = [
151151
"flask>=3.1.3",
152152
"ipykernel>=6.29.5",
153153
"jupyter>=1.1.1",
154-
"litellm>=1.83.0,<1.92.0", # 1.92.0+ drops the universal wheel (no mac/musl/old-glibc/win-arm64); see litellm group
154+
"litellm>=1.84.0,<1.92.0", # 1.92.0+ drops the universal wheel (no mac/musl/old-glibc/win-arm64); see litellm group
155155
"ollama>=0.5.1",
156156
"opencv-python>=4.11.0.86",
157157
"playwright>=1.49.0",

pyrit/prompt_target/http_target/http_target.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import re
88
from collections.abc import Callable
99
from typing import Any
10+
from urllib.parse import urlsplit
1011

1112
import httpx
1213

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

7681
super().__init__(
7782
max_requests_per_minute=max_requests_per_minute,
@@ -82,6 +87,7 @@ def __init__(
8287
self.http_request = http_request
8388
self.callback_function = callback_function
8489
self.prompt_regex_string = prompt_regex_string
90+
self.follow_redirects = follow_redirects
8591
self.httpx_client_kwargs = httpx_client_kwargs or {}
8692

8793
if client and httpx_client_kwargs:
@@ -99,6 +105,7 @@ def _build_identifier(self) -> ComponentIdentifier:
99105
"use_tls": self.use_tls,
100106
"prompt_regex_string": self.prompt_regex_string,
101107
"callback_function": getattr(self.callback_function, "__name__", None),
108+
"follow_redirects": self.follow_redirects,
102109
},
103110
)
104111

@@ -110,6 +117,7 @@ def with_client(
110117
prompt_regex_string: str = "{PROMPT}",
111118
callback_function: Callable[..., Any] | None = None,
112119
max_requests_per_minute: int | None = None,
120+
follow_redirects: bool = True,
113121
) -> "HTTPTarget":
114122
"""
115123
Alternative constructor that accepts a pre-configured httpx client.
@@ -120,6 +128,8 @@ def with_client(
120128
prompt_regex_string: the placeholder for the prompt
121129
callback_function: function to parse HTTP response
122130
max_requests_per_minute: Optional rate limiting
131+
follow_redirects: Whether to follow HTTP redirects. Defaults to True for backward compatibility; set to
132+
False when redirects are unnecessary or the destination must remain fixed.
123133
124134
Returns:
125135
HTTPTarget: an instance of HTTPTarget
@@ -130,6 +140,7 @@ def with_client(
130140
callback_function=callback_function,
131141
max_requests_per_minute=max_requests_per_minute,
132142
client=client,
143+
follow_redirects=follow_redirects,
133144
)
134145

135146
def _inject_prompt_into_request(self, request: MessagePiece) -> str:
@@ -142,8 +153,12 @@ def _inject_prompt_into_request(self, request: MessagePiece) -> str:
142153
143154
Returns:
144155
str: the http request with the prompt added in
156+
157+
Raises:
158+
ValueError: If a multiline prompt would be substituted into the request line or headers.
145159
"""
146160
re_pattern = re.compile(self.prompt_regex_string)
161+
self._validate_prompt_for_template_context(prompt=request.converted_value, pattern=re_pattern)
147162
if re.search(self.prompt_regex_string, self.http_request):
148163
http_request_w_prompt = re_pattern.sub(lambda m: request.converted_value, self.http_request)
149164
else:
@@ -169,6 +184,7 @@ async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Me
169184
http_request_w_prompt = self._inject_prompt_into_request(request)
170185

171186
header_dict, http_body, url, http_method, http_version = self.parse_raw_http_request(http_request_w_prompt)
187+
self._validate_destination(url)
172188

173189
if "Content-Length" in header_dict:
174190
header_dict["Content-Length"] = str(len(http_body))
@@ -191,15 +207,15 @@ async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Me
191207
url=url,
192208
headers=header_dict,
193209
data=http_body,
194-
follow_redirects=True,
210+
follow_redirects=self.follow_redirects,
195211
)
196212
else:
197213
response = await client.request(
198214
method=http_method,
199215
url=url,
200216
headers=header_dict,
201217
content=http_body,
202-
follow_redirects=True,
218+
follow_redirects=self.follow_redirects,
203219
)
204220

205221
response_content = response.content
@@ -296,3 +312,27 @@ def _infer_full_url_from_host(
296312

297313
host = headers_dict["host"]
298314
return f"{http_protocol}{host}{path}"
315+
316+
def _validate_destination(self, url: str) -> None:
317+
destination_origin = self._get_destination_origin(url)
318+
if destination_origin != self._destination_origin:
319+
raise ValueError("Prompt substitution cannot change the configured HTTP destination.")
320+
321+
def _validate_prompt_for_template_context(self, *, prompt: str, pattern: re.Pattern[str]) -> None:
322+
if "\r" not in prompt and "\n" not in prompt:
323+
return
324+
325+
separator = re.search(r"\r?\n\r?\n", self.http_request)
326+
header_end = separator.start() if separator else len(self.http_request)
327+
328+
if any(match.start() < header_end for match in pattern.finditer(self.http_request)):
329+
raise ValueError("Prompts substituted into the HTTP request line or headers cannot contain CR or LF.")
330+
331+
@staticmethod
332+
def _get_destination_origin(url: str) -> tuple[str, str | None, int | None]:
333+
parsed_url = urlsplit(url)
334+
try:
335+
port = parsed_url.port
336+
except ValueError as exc:
337+
raise ValueError(f"Invalid port in HTTP destination: {url}") from exc
338+
return parsed_url.scheme.lower(), parsed_url.hostname, port

0 commit comments

Comments
 (0)