From 50af7dcde01636de2bf644d9371e7a475ab32cb1 Mon Sep 17 00:00:00 2001 From: prbatero <42007693+prbatero@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:37:01 -0400 Subject: [PATCH] fix(api): reject client-supplied workflow state Protect workflow-owned image-layer and inference fields at the HTTP boundary. Allow unchanged values on edits, preserve omitted stored state, reject forged create/update values, and cover the behavior with route and utility tests. --- api/hastefuncapi/function_app.py | 39 ++++++++- .../tests/test_request_field_routes.py | 79 +++++++++++++++++++ .../src/hastegeo/core/utils/request_fields.py | 73 +++++++++++++++++ .../tests/core/utils/test_request_fields.py | 46 +++++++++++ 4 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 api/hastefuncapi/tests/test_request_field_routes.py create mode 100644 hastelib/src/hastegeo/core/utils/request_fields.py create mode 100644 hastelib/tests/core/utils/test_request_fields.py diff --git a/api/hastefuncapi/function_app.py b/api/hastefuncapi/function_app.py index 707cd6c3..923a755f 100644 --- a/api/hastefuncapi/function_app.py +++ b/api/hastefuncapi/function_app.py @@ -80,6 +80,12 @@ from hastegeo.core.utils.data import convert_json_to_geojson, filter_roles from hastegeo.core.utils.logs import Logger from hastegeo.core.utils.metadata import MetadataUtils +from hastegeo.core.utils.request_fields import ( + IMAGE_LAYER_SERVER_MANAGED_FIELDS, + INFERENCE_SERVER_MANAGED_FIELDS, + changed_server_managed_fields, + server_managed_fields_message, +) from hastegeo.core.utils.source_types import normalize_source_type from hastegeo.core.utils.url_allowlist import ( validate_clip_bbox, @@ -1029,6 +1035,13 @@ async def PutLayer(req: func.HttpRequest) -> func.HttpResponse: try: req_body = req.get_json() + supplied_server_fields = changed_server_managed_fields( + req_body, IMAGE_LAYER_SERVER_MANAGED_FIELDS + ) + if not req_body.get("imageLayerId") and supplied_server_fields: + return _bad_request( + server_managed_fields_message(supplied_server_fields) + ) image_data = ImageLayer(**req_body) url_error = validate_image_layer_imagery_urls(image_data) @@ -1061,8 +1074,21 @@ async def PutLayer(req: func.HttpRequest) -> func.HttpResponse: except FileNotFoundError: existing_image_layer = None + changed_server_fields = changed_server_managed_fields( + req_body, + IMAGE_LAYER_SERVER_MANAGED_FIELDS, + existing=existing_image_layer, + ) + if changed_server_fields: + return _bad_request( + server_managed_fields_message(changed_server_fields) + ) + if existing_image_layer: # This is an edit + for field in IMAGE_LAYER_SERVER_MANAGED_FIELDS: + if field in existing_image_layer: + setattr(image_data, field, existing_image_layer[field]) output = image_data else: output = await asyncio.to_thread( @@ -1470,9 +1496,9 @@ async def GetModelArtifact(req: func.HttpRequest) -> func.HttpResponse: # interactive labeler's other artifacts are fetched by range and parsed # in-browser, so they must NOT be forced as downloads). if kind == "gpkg": - headers[ - "Content-Disposition" - ] = f'attachment; filename="building_predictions_{model_id}.gpkg"' + headers["Content-Disposition"] = "; ".join( + ["attachment", f'filename="building_predictions_{model_id}.gpkg"'] + ) if result.etag: headers["ETag"] = ( result.etag if result.etag.startswith('"') else f'"{result.etag}"' @@ -2458,6 +2484,13 @@ async def PutRunInferenceQueueMessage( ) try: req_body = req.get_json() + changed_server_fields = changed_server_managed_fields( + req_body, INFERENCE_SERVER_MANAGED_FIELDS + ) + if changed_server_fields: + return _bad_request( + server_managed_fields_message(changed_server_fields) + ) output = Model(**req_body) if output.creationDate is None: output.creationDate = MetadataUtils.get_timestamp() diff --git a/api/hastefuncapi/tests/test_request_field_routes.py b/api/hastefuncapi/tests/test_request_field_routes.py new file mode 100644 index 00000000..b9951d3d --- /dev/null +++ b/api/hastefuncapi/tests/test_request_field_routes.py @@ -0,0 +1,79 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import io +import json +import os +import unittest +from contextlib import redirect_stderr +from unittest.mock import Mock, patch + +import azure.functions as func + +os.environ.setdefault("DEVELOPMENT_MODE", "true") +os.environ.setdefault("METADATA_STORAGE_TYPE", "local") +os.environ.setdefault("ARTIFACT_STORAGE_TYPE", "local") +os.environ.setdefault("DATA_PATH", "/tmp/haste-request-field-api-tests") +os.environ.setdefault("TEMP_DATA_PATH", "/tmp/haste-request-field-api-tests") + +with redirect_stderr(io.StringIO()): + from api.hastefuncapi import function_app + +PROJECT_ID = "123e4567-e89b-12d3-a456-426614174000" +LAYER_ID = "550e8400-e29b-41d4-a716-446655440000" + + +def make_layer_request(status: str | None) -> func.HttpRequest: + body = { + "projectId": PROJECT_ID, + "imageLayerId": LAYER_ID, + "name": "Renamed layer", + } + if status is not None: + body["status"] = status + return func.HttpRequest( + method="PUT", + url="http://localhost/api/PutLayer", + headers={}, + params={}, + route_params={}, + body=json.dumps(body).encode(), + ) + + +class TestPutLayerServerManagedFields(unittest.IsolatedAsyncioTestCase): + def setUp(self) -> None: + self.processor = Mock() + self.processor.load.return_value = { + "projectId": PROJECT_ID, + "imageLayerId": LAYER_ID, + "name": "Original layer", + "status": "Processed", + } + patcher = patch.object( + function_app, "MetadataProcessor", return_value=self.processor + ) + self.addCleanup(patcher.stop) + patcher.start() + + async def test_edit_accepts_unchanged_server_managed_values(self) -> None: + response = await function_app.PutLayer(make_layer_request("Processed")) + + self.assertEqual(response.status_code, 200) + self.processor.save.assert_called_once() + + async def test_edit_rejects_changed_server_managed_values(self) -> None: + response = await function_app.PutLayer(make_layer_request("Failed")) + + self.assertEqual(response.status_code, 400) + self.processor.save.assert_not_called() + + async def test_partial_edit_preserves_omitted_server_state(self) -> None: + response = await function_app.PutLayer(make_layer_request(None)) + + self.assertEqual(response.status_code, 200) + saved = self.processor.save.call_args.args[1] + self.assertEqual(saved["status"], "Processed") + + +if __name__ == "__main__": + unittest.main() diff --git a/hastelib/src/hastegeo/core/utils/request_fields.py b/hastelib/src/hastegeo/core/utils/request_fields.py new file mode 100644 index 00000000..c004d36a --- /dev/null +++ b/hastelib/src/hastegeo/core/utils/request_fields.py @@ -0,0 +1,73 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +"""Request-boundary rules for persisted workflow state.""" + +from collections.abc import Collection, Mapping +from typing import Any + +IMAGE_LAYER_SERVER_MANAGED_FIELDS = frozenset( + { + "buildingFootprintsUrl", + "creationDate", + "currentStep", + "imageryPath", + "labelProject", + "labelProjectCount", + "labelsUrl", + "modelCount", + "models", + "normalizationMeans", + "normalizationStds", + "postEventMosaicCogImageryUrl", + "postEventPreviewUrls", + "postEventProcessedImageryUrl", + "preEventMosaicCogImageryUrl", + "preEventPreviewUrls", + "preEventProcessedImageryUrl", + "preprocessJob", + "previewSourceImageryUrls", + "processedImageryUrls", + "progressPct", + "rawImageryUrls", + "status", + "statusMessage", + "totalSteps", + "validAreaMaskUrl", + } +) + +INFERENCE_SERVER_MANAGED_FIELDS = frozenset( + { + "currentInferenceTaskId", + "gpkgUrl", + "inferenceCurrentStep", + "inferenceJobs", + "inferenceProgressPct", + "inferenceStatus", + "inferenceStatusMessage", + "inferenceTotalSteps", + "inferenceUid", + "predictedDamageLayerUrl", + } +) + + +def changed_server_managed_fields( + payload: Mapping[str, Any], + protected_fields: Collection[str], + existing: Mapping[str, Any] | None = None, +) -> list[str]: + """Return protected fields supplied for create or changed during update.""" + supplied = set(payload).intersection(protected_fields) + if existing is None: + return sorted(supplied) + return sorted( + field for field in supplied if payload[field] != existing.get(field) + ) + + +def server_managed_fields_message(fields: Collection[str]) -> str: + """Build a stable boundary-validation message.""" + return "Server-managed fields cannot be supplied or changed: " + ", ".join( + sorted(fields) + ) diff --git a/hastelib/tests/core/utils/test_request_fields.py b/hastelib/tests/core/utils/test_request_fields.py new file mode 100644 index 00000000..6c73b62a --- /dev/null +++ b/hastelib/tests/core/utils/test_request_fields.py @@ -0,0 +1,46 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import unittest + +from hastegeo.core.utils.request_fields import ( + changed_server_managed_fields, + server_managed_fields_message, +) + + +class TestServerManagedFields(unittest.TestCase): + def test_create_rejects_supplied_protected_fields(self) -> None: + result = changed_server_managed_fields( + {"name": "Layer", "status": "Processed"}, + {"status", "progressPct"}, + ) + + self.assertEqual(result, ["status"]) + + def test_update_allows_unchanged_protected_fields(self) -> None: + result = changed_server_managed_fields( + {"name": "Renamed", "status": "Processed"}, + {"status"}, + existing={"name": "Original", "status": "Processed"}, + ) + + self.assertEqual(result, []) + + def test_update_rejects_changed_and_new_protected_fields(self) -> None: + result = changed_server_managed_fields( + {"status": "Processed", "progressPct": 100}, + {"status", "progressPct"}, + existing={"status": "Queued"}, + ) + + self.assertEqual(result, ["progressPct", "status"]) + + def test_message_is_stable_and_sorted(self) -> None: + self.assertEqual( + server_managed_fields_message(["status", "gpkgUrl"]), + "Server-managed fields cannot be supplied or changed: gpkgUrl, status", + ) + + +if __name__ == "__main__": + unittest.main()