Skip to content
Open
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
16 changes: 16 additions & 0 deletions openeo_driver/dummy/dummy_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,22 @@ def get_result_assets(self, job_id: str, user_id: str) -> Dict[str, dict]:
"type": "Polygon",
"coordinates": [[[0.0, 50.0], [0.0, 55.0], [5.0, 55.0], [5.0, 50.0], [0.0, 50.0]]],
},
"datetime": "1970-01-01T00:00:00Z",
},
"subfolder/subsubfolder/output.tiff": {
"output_dir": f"{self._output_root()}/{job_id}",
"href": f"{self._output_root()}/{job_id}/subfolder/subsubfolder/output.tiff",
"type": "image/tiff; application=geotiff",
"roles": ["data"],
"bands": [Band(name="NDVI", wavelength_um=1.23)],
"nodata": 123,
"instruments": "MSI",
"bbox": [0.0, 50.0, 5.0, 55.0],
"geometry": {
"type": "Polygon",
"coordinates": [[[0.0, 50.0], [0.0, 55.0], [5.0, 55.0], [5.0, 50.0], [0.0, 50.0]]],
},
"datetime": "1970-01-01T00:00:00Z",
},
}
elif job_id == "j-26032411111111111111111111111111":
Expand Down
6 changes: 6 additions & 0 deletions openeo_driver/urlsigning.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import operator
import re
import time
from functools import reduce
from hashlib import md5
Expand All @@ -10,6 +11,11 @@
_log = logging.getLogger(__name__)


def is_secure_key(s: str) -> bool:
"""Return True if s looks like a URL signing key (MD5 hex digest)."""
return bool(re.compile(r"^[0-9a-f]{32}$").match(s))


class UrlSigner:
def __init__(self, secret: str, expiration: int = None):
self._secret = secret
Expand Down
44 changes: 37 additions & 7 deletions openeo_driver/views.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import binascii
import copy
import functools
import json
Expand Down Expand Up @@ -57,6 +58,7 @@
from openeo_driver.datacube import DriverMlModel
from openeo_driver.dry_run import DryRunDataTracer
from openeo_driver.errors import (
CredentialsInvalidException,
FeatureUnsupportedException,
FilePathInvalidException,
InternalException,
Expand All @@ -79,6 +81,7 @@
from openeo_driver.util.logging import ExtraLoggingFilter, FlaskRequestCorrelationIdLogging
from openeo_driver.util.stac import sniff_stac_extension_prefix
from openeo_driver.util.stac_utils import get_files_from_stac_catalog
from openeo_driver.urlsigning import is_secure_key
from openeo_driver.utils import EvalEnv, smart_bool


Expand Down Expand Up @@ -1489,30 +1492,48 @@ def _stream_from_s3(s3_url, *, filename, mimetype: Optional[str], bytes_range: O

raise

@blueprint.route('/jobs/<job_id>/results/items/<user_base64>/<secure_key>/<item_id>', methods=['GET'])
@blueprint.route("/jobs/<job_id>/results/items/<user_base64>/<secure_key>/<path:item_id>", methods=["GET"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid treating deep item paths as signed URLs

With the signed rule now using <path:item_id> and being registered before the bearer-auth item route below, an unsigned nested item ID with three or more components (for example tiles/2024/01.tif) matches this signed route as user_base64=tiles, secure_key=2024, item_id=01.tif instead of reaching the bearer-auth handler. That makes deeper nested results fail signature/base64 verification even when the caller supplies normal bearer auth; the same pattern on the items11 signed route has the same effect.

Useful? React with 👍 / 👎.

def get_job_result_item_signed(job_id, user_base64, secure_key, item_id):
try:
user_id = user_id_b64_decode(user_base64)
except (binascii.Error, UnicodeDecodeError):
user_id = None
if user_id is None or not is_secure_key(secure_key):
full_item_id = "/".join([user_base64, secure_key, item_id])
user = auth_handler.get_user_from_bearer_token(request)
return _get_job_result_item(job_id, full_item_id, user.user_id)
if not is_secure_key(secure_key):
raise CredentialsInvalidException()
expires = request.args.get('expires')
signer = get_backend_config().url_signer
user_id = user_id_b64_decode(user_base64)
signer.verify_job_item(signature=secure_key, job_id=job_id, user_id=user_id, item_id=item_id, expires=expires)
return _get_job_result_item(job_id, item_id, user_id)

@api_endpoint
@blueprint.route('/jobs/<job_id>/results/items11/<user_base64>/<secure_key>/<item_id>', methods=['GET'])
@blueprint.route("/jobs/<job_id>/results/items11/<user_base64>/<secure_key>/<path:item_id>", methods=["GET"])
def get_job_result_item11_signed(job_id, user_base64, secure_key, item_id):
try:
user_id = user_id_b64_decode(user_base64)
except (binascii.Error, UnicodeDecodeError):
user_id = None
if user_id is None or not is_secure_key(secure_key):
full_item_id = "/".join([user_base64, secure_key, item_id])
user = auth_handler.get_user_from_bearer_token(request)
return _get_job_result_item11(job_id, full_item_id, user.user_id)
if not is_secure_key(secure_key):
raise CredentialsInvalidException()
expires = request.args.get('expires')
signer = get_backend_config().url_signer
user_id = user_id_b64_decode(user_base64)
signer.verify_job_item(signature=secure_key, job_id=job_id, user_id=user_id, item_id=item_id, expires=expires)
return _get_job_result_item11(job_id, item_id, user_id)

@blueprint.route('/jobs/<job_id>/results/items/<item_id>', methods=['GET'])
@blueprint.route("/jobs/<job_id>/results/items/<path:item_id>", methods=["GET"])
@auth_handler.requires_bearer_auth
def get_job_result_item(job_id: str, item_id: str, user: User) -> flask.Response:
return _get_job_result_item(job_id, item_id, user.user_id)

@api_endpoint(version=ComparableVersion("1.1.0").or_higher)
@blueprint.route('/jobs/<job_id>/results/items11/<item_id>', methods=['GET'])
@blueprint.route("/jobs/<job_id>/results/items11/<path:item_id>", methods=["GET"])
@auth_handler.requires_bearer_auth
def get_job_result_item11(job_id: str, item_id: str, user: User) -> flask.Response:
return _get_job_result_item11(job_id, item_id, user.user_id)
Expand Down Expand Up @@ -1927,9 +1948,18 @@ def download_job_result(job_id, filename, user: User):

@blueprint.route("/jobs/<job_id>/results/assets/<user_base64>/<secure_key>/<path:filename>", methods=["GET"])
def download_job_result_signed(job_id, user_base64, secure_key, filename):
try:
user_id = user_id_b64_decode(user_base64)
except (binascii.Error, UnicodeDecodeError):
user_id = None
if user_id is None or not is_secure_key(secure_key):
full_filename = "/".join([user_base64, secure_key, filename])
user = auth_handler.get_user_from_bearer_token(request)
return _download_job_result(job_id=job_id, filename=full_filename, user_id=user.user_id)
if not is_secure_key(secure_key):
raise CredentialsInvalidException()
expires = request.args.get('expires')
signer = get_backend_config().url_signer
user_id = user_id_b64_decode(user_base64)
signer.verify_job_asset(
signature=secure_key,
job_id=job_id, user_id=user_id, filename=filename, expires=expires
Expand Down
42 changes: 40 additions & 2 deletions tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3525,12 +3525,12 @@ def test_download_result_nested_path(self, api110, tmp_path):
output_root = Path(tmp_path)
jobs = {"j-24111211111111111111111111111111": {"status": "finished"}}
with self._fresh_job_registry(output_root=output_root, jobs=jobs):
output = output_root / "j-24111211111111111111111111111111/subfolder/output.tiff"
output = output_root / "j-24111211111111111111111111111111/subfolder/subsubfolder/output.tiff"
output.parent.mkdir(parents=True)
with output.open("wb") as f:
f.write(b"tiffdata")
resp = api110.get(
"/jobs/j-24111211111111111111111111111111/results/assets/subfolder/output.tiff",
"/jobs/j-24111211111111111111111111111111/results/assets/subfolder/subsubfolder/output.tiff",
headers=self.AUTH_HEADER,
)
assert resp.assert_status_code(200).data == b"tiffdata"
Expand Down Expand Up @@ -4024,6 +4024,44 @@ def test_get_vector_cube_job_result_item(
extensions=resp_data.get("stac_extensions", []),
)

def test_get_job_result_item_with_temporal_extent_on_asset_nested(self, flask_app, api110):
with self._fresh_job_registry():
resp = api110.get(
f"/jobs/j-24111211111111111111111111111111/results/items/subfolder/output.tiff",
headers=self.AUTH_HEADER,
)

resp_data = resp.assert_status_code(200).json
assert resp_data["assets"]["subfolder/output.tiff"]["eo:bands"][0]["name"] == "NDVI"

assert resp.headers["Content-Type"] == "application/geo+json"

pystac.validation.stac_validator.JsonSchemaSTACValidator().validate(
stac_dict=resp_data,
stac_object_type=pystac.STACObjectType.ITEM,
stac_version=resp_data.get("stac_version", "1.0.0"),
extensions=resp_data.get("stac_extensions", []),
)

def test_get_job_result_item_with_temporal_extent_on_asset_double_nested(self, flask_app, api110):
with self._fresh_job_registry():
resp = api110.get(
f"/jobs/j-24111211111111111111111111111111/results/items/subfolder/subsubfolder/output.tiff",
headers=self.AUTH_HEADER,
)

resp_data = resp.assert_status_code(200).json
assert resp_data["assets"]["subfolder/subsubfolder/output.tiff"]["eo:bands"][0]["name"] == "NDVI"

assert resp.headers["Content-Type"] == "application/geo+json"

pystac.validation.stac_validator.JsonSchemaSTACValidator().validate(
stac_dict=resp_data,
stac_object_type=pystac.STACObjectType.ITEM,
stac_version=resp_data.get("stac_version", "1.0.0"),
extensions=resp_data.get("stac_extensions", []),
)

def test_get_job_result_item_with_temporal_extent_on_asset(self, flask_app, api110):
with self._fresh_job_registry():
resp = api110.get(
Expand Down