From 0eb781f195046be6b13c3a8ed49f18a3558bc286 Mon Sep 17 00:00:00 2001 From: deacon Date: Sun, 15 Mar 2026 23:13:09 -0400 Subject: [PATCH 1/6] feat: add name filter query parameter to payload list API (#3055) Add optional `?name=` query parameter to `GET /api/v2/payloads` that performs a case-insensitive substring match on payload filenames, enabling callers to search/filter the payload list without retrieving and filtering the full set client-side. --- app/api/v2/handlers/payload_api.py | 9 +++++- app/api/v2/schemas/payload_schemas.py | 1 + tests/api/v2/handlers/test_payloads_api.py | 32 ++++++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/app/api/v2/handlers/payload_api.py b/app/api/v2/handlers/payload_api.py index 1b034a332..97a96ea04 100644 --- a/app/api/v2/handlers/payload_api.py +++ b/app/api/v2/handlers/payload_api.py @@ -27,7 +27,8 @@ def add_routes(self, app: web.Application): @aiohttp_apispec.docs(tags=['payloads'], summary='Retrieve payloads', - description='Retrieves all stored payloads.') + description='Retrieves all stored payloads. Supports optional filtering by name ' + '(case-insensitive substring match via the `name` query parameter).') @aiohttp_apispec.querystring_schema(PayloadQuerySchema) @aiohttp_apispec.response_schema(PayloadSchema(), description='Returns a list of all payloads in PayloadSchema format.') @@ -35,6 +36,7 @@ async def get_payloads(self, request: web.Request): sort: bool = request['querystring'].get('sort') exclude_plugins: bool = request['querystring'].get('exclude_plugins') add_path: bool = request['querystring'].get('add_path') + name_filter: str = request['querystring'].get('name') cwd = pathlib.Path.cwd() payload_dirs = [cwd / 'data' / 'payloads'] @@ -52,6 +54,11 @@ async def get_payloads(self, request: web.Request): } payloads = list(payloads) + + if name_filter: + name_filter_lower = name_filter.lower() + payloads = [p for p in payloads if name_filter_lower in p.lower()] + if sort: payloads.sort() diff --git a/app/api/v2/schemas/payload_schemas.py b/app/api/v2/schemas/payload_schemas.py index 22d8701f4..c27b5d84b 100644 --- a/app/api/v2/schemas/payload_schemas.py +++ b/app/api/v2/schemas/payload_schemas.py @@ -5,6 +5,7 @@ class PayloadQuerySchema(schema.Schema): sort = fields.Boolean(required=False, load_default=False) exclude_plugins = fields.Boolean(required=False, load_default=False) add_path = fields.Boolean(required=False, load_default=False) + name = fields.String(required=False, load_default=None) class PayloadSchema(schema.Schema): diff --git a/tests/api/v2/handlers/test_payloads_api.py b/tests/api/v2/handlers/test_payloads_api.py index 8570f35ba..5b177ea6a 100644 --- a/tests/api/v2/handlers/test_payloads_api.py +++ b/tests/api/v2/handlers/test_payloads_api.py @@ -49,6 +49,38 @@ async def test_get_payloads(self, api_v2_client, api_cookies, expected_payload_f assert filtered_payload_file_names == expected_payload_file_names + async def test_get_payloads_name_filter_matches(self, api_v2_client, api_cookies, expected_payload_file_names): + # All test payloads are created with a "payload_" prefix, so filtering by "payload_" must return all of them. + resp = await api_v2_client.get('/api/v2/payloads?name=payload_', cookies=api_cookies) + assert resp.status == HTTPStatus.OK + payload_file_names = await resp.json() + + filtered_payload_file_names = { + file_name for file_name in payload_file_names + if file_name in expected_payload_file_names + } + assert filtered_payload_file_names == expected_payload_file_names + + async def test_get_payloads_name_filter_no_match(self, api_v2_client, api_cookies, expected_payload_file_names): + # Use a suffix that is extremely unlikely to appear in any real payload file name. + resp = await api_v2_client.get('/api/v2/payloads?name=__no_match_xyzzy__', cookies=api_cookies) + assert resp.status == HTTPStatus.OK + payload_file_names = await resp.json() + assert payload_file_names == [] + + async def test_get_payloads_name_filter_case_insensitive(self, api_v2_client, api_cookies, + expected_payload_file_names): + # "PAYLOAD_" in uppercase must still match files prefixed with "payload_". + resp = await api_v2_client.get('/api/v2/payloads?name=PAYLOAD_', cookies=api_cookies) + assert resp.status == HTTPStatus.OK + payload_file_names = await resp.json() + + filtered_payload_file_names = { + file_name for file_name in payload_file_names + if file_name in expected_payload_file_names + } + assert filtered_payload_file_names == expected_payload_file_names + async def test_unauthorized_get_payloads(self, api_v2_client): resp = await api_v2_client.get('/api/v2/payloads') assert resp.status == HTTPStatus.UNAUTHORIZED From 345ec26d9500be01c0b7fce443b2a6bbd6e2760d Mon Sep 17 00:00:00 2001 From: deacon Date: Mon, 16 Mar 2026 00:04:40 -0400 Subject: [PATCH 2/6] fix: address Copilot review feedback on payload name filter - Change name_filter annotation to Optional[str] - Filter only on PurePosixPath.name to avoid matching directory segments when add_path=True (e.g. 'plugins/stockpile/payloads/file.txt' should only match on 'file.txt') - Strengthen filter tests to assert non-matching payloads are excluded --- app/api/v2/handlers/payload_api.py | 5 +++-- tests/api/v2/handlers/test_payloads_api.py | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/api/v2/handlers/payload_api.py b/app/api/v2/handlers/payload_api.py index 97a96ea04..b09f43a08 100644 --- a/app/api/v2/handlers/payload_api.py +++ b/app/api/v2/handlers/payload_api.py @@ -4,6 +4,7 @@ import pathlib import re from io import IOBase +from typing import Optional import aiohttp_apispec from aiohttp import web @@ -36,7 +37,7 @@ async def get_payloads(self, request: web.Request): sort: bool = request['querystring'].get('sort') exclude_plugins: bool = request['querystring'].get('exclude_plugins') add_path: bool = request['querystring'].get('add_path') - name_filter: str = request['querystring'].get('name') + name_filter: Optional[str] = request['querystring'].get('name') cwd = pathlib.Path.cwd() payload_dirs = [cwd / 'data' / 'payloads'] @@ -57,7 +58,7 @@ async def get_payloads(self, request: web.Request): if name_filter: name_filter_lower = name_filter.lower() - payloads = [p for p in payloads if name_filter_lower in p.lower()] + payloads = [p for p in payloads if name_filter_lower in pathlib.PurePosixPath(p).name.lower()] if sort: payloads.sort() diff --git a/tests/api/v2/handlers/test_payloads_api.py b/tests/api/v2/handlers/test_payloads_api.py index 5b177ea6a..45120e52e 100644 --- a/tests/api/v2/handlers/test_payloads_api.py +++ b/tests/api/v2/handlers/test_payloads_api.py @@ -61,6 +61,10 @@ async def test_get_payloads_name_filter_matches(self, api_v2_client, api_cookies } assert filtered_payload_file_names == expected_payload_file_names + # All returned payloads must match the filter — non-matching payloads must be excluded. + import pathlib as _pathlib + assert all('payload_' in _pathlib.PurePosixPath(p).name.lower() for p in payload_file_names) + async def test_get_payloads_name_filter_no_match(self, api_v2_client, api_cookies, expected_payload_file_names): # Use a suffix that is extremely unlikely to appear in any real payload file name. resp = await api_v2_client.get('/api/v2/payloads?name=__no_match_xyzzy__', cookies=api_cookies) @@ -81,6 +85,10 @@ async def test_get_payloads_name_filter_case_insensitive(self, api_v2_client, ap } assert filtered_payload_file_names == expected_payload_file_names + # All returned payloads must match the filter — non-matching payloads must be excluded. + import pathlib as _pathlib + assert all('payload_' in _pathlib.PurePosixPath(p).name.lower() for p in payload_file_names) + async def test_unauthorized_get_payloads(self, api_v2_client): resp = await api_v2_client.get('/api/v2/payloads') assert resp.status == HTTPStatus.UNAUTHORIZED From 48daeb2cb2dc05ec921ee76a77baedaa5c902d46 Mon Sep 17 00:00:00 2001 From: deacon Date: Mon, 16 Mar 2026 09:21:27 -0400 Subject: [PATCH 3/6] Fix flake8 E127: correct continuation line indentation in test_payloads_api.py --- tests/api/v2/handlers/test_payloads_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/api/v2/handlers/test_payloads_api.py b/tests/api/v2/handlers/test_payloads_api.py index 45120e52e..cd503df3c 100644 --- a/tests/api/v2/handlers/test_payloads_api.py +++ b/tests/api/v2/handlers/test_payloads_api.py @@ -73,7 +73,7 @@ async def test_get_payloads_name_filter_no_match(self, api_v2_client, api_cookie assert payload_file_names == [] async def test_get_payloads_name_filter_case_insensitive(self, api_v2_client, api_cookies, - expected_payload_file_names): + expected_payload_file_names): # "PAYLOAD_" in uppercase must still match files prefixed with "payload_". resp = await api_v2_client.get('/api/v2/payloads?name=PAYLOAD_', cookies=api_cookies) assert resp.status == HTTPStatus.OK From 0fbfa10a1f534b816bf3bd122fa20ca32cb0f0c7 Mon Sep 17 00:00:00 2001 From: deacon Date: Mon, 16 Mar 2026 09:59:53 -0400 Subject: [PATCH 4/6] Address Copilot review: use PurePath for cross-platform compatibility Replace PurePosixPath with PurePath in both the payload handler and tests so basename extraction works correctly on Windows where paths may use backslash separators. Move pathlib import to module scope. --- app/api/v2/handlers/payload_api.py | 2 +- tests/api/v2/handlers/test_payloads_api.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/api/v2/handlers/payload_api.py b/app/api/v2/handlers/payload_api.py index b09f43a08..6c6350348 100644 --- a/app/api/v2/handlers/payload_api.py +++ b/app/api/v2/handlers/payload_api.py @@ -58,7 +58,7 @@ async def get_payloads(self, request: web.Request): if name_filter: name_filter_lower = name_filter.lower() - payloads = [p for p in payloads if name_filter_lower in pathlib.PurePosixPath(p).name.lower()] + payloads = [p for p in payloads if name_filter_lower in pathlib.PurePath(p).name.lower()] if sort: payloads.sort() diff --git a/tests/api/v2/handlers/test_payloads_api.py b/tests/api/v2/handlers/test_payloads_api.py index cd503df3c..1717318ea 100644 --- a/tests/api/v2/handlers/test_payloads_api.py +++ b/tests/api/v2/handlers/test_payloads_api.py @@ -1,4 +1,5 @@ import os +import pathlib import tempfile from http import HTTPStatus @@ -62,8 +63,7 @@ async def test_get_payloads_name_filter_matches(self, api_v2_client, api_cookies assert filtered_payload_file_names == expected_payload_file_names # All returned payloads must match the filter — non-matching payloads must be excluded. - import pathlib as _pathlib - assert all('payload_' in _pathlib.PurePosixPath(p).name.lower() for p in payload_file_names) + assert all('payload_' in pathlib.PurePath(p).name.lower() for p in payload_file_names) async def test_get_payloads_name_filter_no_match(self, api_v2_client, api_cookies, expected_payload_file_names): # Use a suffix that is extremely unlikely to appear in any real payload file name. @@ -86,8 +86,7 @@ async def test_get_payloads_name_filter_case_insensitive(self, api_v2_client, ap assert filtered_payload_file_names == expected_payload_file_names # All returned payloads must match the filter — non-matching payloads must be excluded. - import pathlib as _pathlib - assert all('payload_' in _pathlib.PurePosixPath(p).name.lower() for p in payload_file_names) + assert all('payload_' in pathlib.PurePath(p).name.lower() for p in payload_file_names) async def test_unauthorized_get_payloads(self, api_v2_client): resp = await api_v2_client.get('/api/v2/payloads') From 7bb07ce09fadf1e97d4b3901613503b0ef9986e9 Mon Sep 17 00:00:00 2001 From: deacon-mp Date: Thu, 2 Apr 2026 19:29:43 -0400 Subject: [PATCH 5/6] Address Copilot review: schema fix, stronger tests, combination test - Add allow_none=True to PayloadQuerySchema name field - Parametrize filter tests (matches + case-insensitive) - Strengthen assertions to verify no false positives - Add combination test with sort=true and add_path=true --- app/api/v2/schemas/payload_schemas.py | 2 +- tests/api/v2/handlers/test_payloads_api.py | 47 +++++++++------------- 2 files changed, 20 insertions(+), 29 deletions(-) diff --git a/app/api/v2/schemas/payload_schemas.py b/app/api/v2/schemas/payload_schemas.py index c27b5d84b..a03af81a6 100644 --- a/app/api/v2/schemas/payload_schemas.py +++ b/app/api/v2/schemas/payload_schemas.py @@ -5,7 +5,7 @@ class PayloadQuerySchema(schema.Schema): sort = fields.Boolean(required=False, load_default=False) exclude_plugins = fields.Boolean(required=False, load_default=False) add_path = fields.Boolean(required=False, load_default=False) - name = fields.String(required=False, load_default=None) + name = fields.String(required=False, load_default=None, allow_none=True) class PayloadSchema(schema.Schema): diff --git a/tests/api/v2/handlers/test_payloads_api.py b/tests/api/v2/handlers/test_payloads_api.py index 1717318ea..463d3f74e 100644 --- a/tests/api/v2/handlers/test_payloads_api.py +++ b/tests/api/v2/handlers/test_payloads_api.py @@ -50,43 +50,34 @@ async def test_get_payloads(self, api_v2_client, api_cookies, expected_payload_f assert filtered_payload_file_names == expected_payload_file_names - async def test_get_payloads_name_filter_matches(self, api_v2_client, api_cookies, expected_payload_file_names): - # All test payloads are created with a "payload_" prefix, so filtering by "payload_" must return all of them. - resp = await api_v2_client.get('/api/v2/payloads?name=payload_', cookies=api_cookies) + @pytest.mark.parametrize('query_name', ['payload_', 'PAYLOAD_']) + async def test_get_payloads_name_filter(self, api_v2_client, api_cookies, expected_payload_file_names, query_name): + resp = await api_v2_client.get(f'/api/v2/payloads?name={query_name}', cookies=api_cookies) assert resp.status == HTTPStatus.OK payload_file_names = await resp.json() - filtered_payload_file_names = { - file_name for file_name in payload_file_names - if file_name in expected_payload_file_names - } - assert filtered_payload_file_names == expected_payload_file_names - - # All returned payloads must match the filter — non-matching payloads must be excluded. + # All expected payloads should be present + assert expected_payload_file_names <= set(payload_file_names) + # Every returned payload must match the filter (no false positives) assert all('payload_' in pathlib.PurePath(p).name.lower() for p in payload_file_names) - async def test_get_payloads_name_filter_no_match(self, api_v2_client, api_cookies, expected_payload_file_names): - # Use a suffix that is extremely unlikely to appear in any real payload file name. + async def test_get_payloads_name_filter_no_match(self, api_v2_client, api_cookies): resp = await api_v2_client.get('/api/v2/payloads?name=__no_match_xyzzy__', cookies=api_cookies) assert resp.status == HTTPStatus.OK - payload_file_names = await resp.json() - assert payload_file_names == [] + assert await resp.json() == [] - async def test_get_payloads_name_filter_case_insensitive(self, api_v2_client, api_cookies, - expected_payload_file_names): - # "PAYLOAD_" in uppercase must still match files prefixed with "payload_". - resp = await api_v2_client.get('/api/v2/payloads?name=PAYLOAD_', cookies=api_cookies) + async def test_get_payloads_name_filter_with_sort_and_add_path(self, api_v2_client, api_cookies, + expected_payload_file_names): + resp = await api_v2_client.get('/api/v2/payloads?name=payload_&sort=true&add_path=true', cookies=api_cookies) assert resp.status == HTTPStatus.OK - payload_file_names = await resp.json() - - filtered_payload_file_names = { - file_name for file_name in payload_file_names - if file_name in expected_payload_file_names - } - assert filtered_payload_file_names == expected_payload_file_names - - # All returned payloads must match the filter — non-matching payloads must be excluded. - assert all('payload_' in pathlib.PurePath(p).name.lower() for p in payload_file_names) + payload_paths = await resp.json() + + # Results should be sorted + assert payload_paths == sorted(payload_paths) + # Every returned path's filename must match the filter + assert all('payload_' in pathlib.PurePath(p).name.lower() for p in payload_paths) + # Results should contain paths (not bare filenames) + assert all(os.sep in p or '/' in p for p in payload_paths) async def test_unauthorized_get_payloads(self, api_v2_client): resp = await api_v2_client.get('/api/v2/payloads') From 43520d98b7a669614804374f348bfc63a2dfa7a8 Mon Sep 17 00:00:00 2001 From: deacon-mp Date: Thu, 2 Apr 2026 19:37:57 -0400 Subject: [PATCH 6/6] Fix flake8 E127: continuation line indentation --- tests/api/v2/handlers/test_payloads_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/api/v2/handlers/test_payloads_api.py b/tests/api/v2/handlers/test_payloads_api.py index 463d3f74e..4ffa34eb7 100644 --- a/tests/api/v2/handlers/test_payloads_api.py +++ b/tests/api/v2/handlers/test_payloads_api.py @@ -66,8 +66,8 @@ async def test_get_payloads_name_filter_no_match(self, api_v2_client, api_cookie assert resp.status == HTTPStatus.OK assert await resp.json() == [] - async def test_get_payloads_name_filter_with_sort_and_add_path(self, api_v2_client, api_cookies, - expected_payload_file_names): + async def test_get_payloads_name_filter_with_sort_and_add_path( + self, api_v2_client, api_cookies, expected_payload_file_names): resp = await api_v2_client.get('/api/v2/payloads?name=payload_&sort=true&add_path=true', cookies=api_cookies) assert resp.status == HTTPStatus.OK payload_paths = await resp.json()