Skip to content

Commit 69a9cfc

Browse files
committed
OAProc: secure subscriber URLs in requests
1 parent 189ed1f commit 69a9cfc

7 files changed

Lines changed: 96 additions & 22 deletions

File tree

docs/source/configuration.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,10 @@ default.
292292
type: process # REQUIRED (collection, process, or stac-collection)
293293
processor:
294294
name: HelloWorld # Python path of process definition
295+
# optional, allow for internal HTTP request execution
296+
# if set to True, enables requests to link local ranges and loopback
297+
# default: False
298+
allow_internal_requests: True
295299
296300
297301
.. seealso::

pygeoapi/process/base.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# Authors: Tom Kralidis <tomkralidis@gmail.com>
44
# Francesco Martinelli <francesco.martinelli@ingv.it>
55
#
6-
# Copyright (c) 2022 Tom Kralidis
6+
# Copyright (c) 2026 Tom Kralidis
77
# Copyright (c) 2024 Francesco Martinelli
88
#
99
# Permission is hereby granted, free of charge, to any person
@@ -53,6 +53,8 @@ def __init__(self, processor_def: dict, process_metadata: dict):
5353
self.name = processor_def['name']
5454
self.metadata = process_metadata
5555
self.supports_outputs = False
56+
self.allow_internal_requests = processor_def.get(
57+
'allow_internal_requests', False)
5658

5759
def set_job_id(self, job_id: str) -> None:
5860
"""

pygeoapi/process/manager/base.py

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,12 @@
4646
BaseProcessor,
4747
JobNotFoundError,
4848
JobResultNotFoundError,
49+
ProcessorExecuteError,
4950
UnknownProcessError,
5051
)
5152
from pygeoapi.util import (
5253
get_current_datetime,
54+
is_request_allowed,
5355
JobStatus,
5456
ProcessExecutionMode,
5557
RequestedProcessExecutionMode,
@@ -105,7 +107,11 @@ def get_processor(self, process_id: str) -> BaseProcessor:
105107
except KeyError as err:
106108
raise UnknownProcessError('Invalid process identifier') from err
107109
else:
108-
return load_plugin('process', process_conf['processor'])
110+
pp = load_plugin('process', process_conf['processor'])
111+
pp.allow_internal_requests = process_conf.get(
112+
'allow_internal_requests', False)
113+
114+
return pp
109115

110116
def get_jobs(self,
111117
status: JobStatus = None,
@@ -394,13 +400,13 @@ def execute_process(
394400
"""
395401

396402
job_id = str(uuid.uuid1())
397-
processor = self.get_processor(process_id)
398-
processor.set_job_id(job_id)
403+
self.processor = self.get_processor(process_id)
404+
self.processor.set_job_id(job_id)
399405
extra_execute_handler_parameters = {
400406
'requested_response': requested_response
401407
}
402408

403-
job_control_options = processor.metadata.get(
409+
job_control_options = self.processor.metadata.get(
404410
'jobControlOptions', [])
405411

406412
if execution_mode == RequestedProcessExecutionMode.respond_async:
@@ -473,7 +479,7 @@ def execute_process(
473479
# TODO: handler's response could also be allowed to include more HTTP
474480
# headers
475481
mime_type, outputs, status = handler(
476-
processor,
482+
self.processor,
477483
job_id,
478484
data_dict,
479485
requested_outputs,
@@ -483,26 +489,37 @@ def execute_process(
483489

484490
def _send_in_progress_notification(self, subscriber: Optional[Subscriber]):
485491
if subscriber and subscriber.in_progress_uri:
486-
response = requests.post(subscriber.in_progress_uri, json={})
487-
LOGGER.debug(
488-
f'In progress notification response: {response.status_code}'
489-
)
492+
self.__do_subscriber_request(subscriber.in_progress_uri)
490493

491494
def _send_success_notification(
492495
self, subscriber: Optional[Subscriber], outputs: Any
493496
):
494-
if subscriber:
495-
response = requests.post(subscriber.success_uri, json=outputs)
496-
LOGGER.debug(
497-
f'Success notification response: {response.status_code}'
498-
)
497+
if subscriber and subscriber.success_uri:
498+
self.__do_subscriber_request(subscriber.success_uri, outputs)
499499

500500
def _send_failed_notification(self, subscriber: Optional[Subscriber]):
501501
if subscriber and subscriber.failed_uri:
502-
response = requests.post(subscriber.failed_uri, json={})
503-
LOGGER.debug(
504-
f'Failed notification response: {response.status_code}'
505-
)
502+
self.__do_subscriber_request(subscriber.failed_uri)
503+
504+
def __do_subscriber_request(self, url: str, data: dict = {}) -> None:
505+
"""
506+
Helper function to execute a subscriber URL via HTTP POST
507+
508+
:param url: `str` of URL
509+
:param data: `dict` of request payload
510+
511+
:returns: `None`
512+
"""
513+
514+
if not is_request_allowed(url, self.processor.allow_internal_requests):
515+
msg = 'URL not allowed'
516+
LOGGER.error(f'{msg}: {url}')
517+
raise ProcessorExecuteError(msg)
518+
519+
response = requests.post(url, json=data)
520+
LOGGER.debug(
521+
f'Response: {response.status_code}'
522+
)
506523

507524
def __repr__(self):
508525
return f'<BaseManager> {self.name}'

pygeoapi/provider/filesystem.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def get_data_path(self, baseurl, urlpath, dirpath):
7878
child_links = []
7979

8080
if '..' in dirpath:
81-
msg = f'Invalid path requested'
81+
msg = 'Invalid path requested'
8282
LOGGER.error(f'{msg}: {dirpath}')
8383
raise ProviderInvalidQueryError(msg)
8484

pygeoapi/resources/schemas/config/pygeoapi-config-0.x.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -682,7 +682,11 @@ properties:
682682
For custom built plugins, use the import path (e.g. `mypackage.provider.MyProvider`)
683683
required:
684684
- name
685-
required:
685+
allow_internal_requests:
686+
type: boolean
687+
description: whether to allow internal HTTP requests
688+
default: false
689+
requred:
686690
- type
687691
- processor
688692
definitions:

pygeoapi/util.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,15 @@
3636
from decimal import Decimal
3737
from enum import Enum
3838
from heapq import heappush
39+
import ipaddress
3940
import json
4041
import logging
4142
import mimetypes
4243
import os
4344
import pathlib
4445
from pathlib import Path
4546
import re
47+
import socket
4648
from typing import Any, IO, Union, List, Optional
4749
from urllib.parse import urlparse
4850
from urllib.request import urlopen
@@ -754,3 +756,30 @@ def remove_url_auth(url: str) -> str:
754756
u = urlparse(url)
755757
auth = f'{u.username}:{u.password}@'
756758
return url.replace(auth, '')
759+
760+
761+
def is_request_allowed(url: str, allow_internal: bool = False) -> bool:
762+
"""
763+
Test whether an HTTP request is allowed to be executed
764+
765+
:param url: `str` of URL
766+
:param allow_internal: `bool` of whether internal requests are
767+
allowed (default `False`)
768+
769+
:returns: `bool` of whether HTTP request execution is allowed
770+
"""
771+
772+
is_allowed = False
773+
774+
u = urlparse(url)
775+
776+
ip = socket.gethostbyname(u.hostname)
777+
778+
is_private = ipaddress.ip_address(ip).is_private
779+
780+
if not is_private:
781+
is_allowed = True
782+
if is_private and allow_internal:
783+
is_allowed = True
784+
785+
return is_allowed

tests/other/test_util.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
#
33
# Authors: Tom Kralidis <tomkralidis@gmail.com>
44
#
5-
# Copyright (c) 2025 Tom Kralidis
5+
# Copyright (c) 2026 Tom Kralidis
66
#
77
# Permission is hereby granted, free of charge, to any person
88
# obtaining a copy of this software and associated documentation
@@ -321,3 +321,21 @@ def test_get_choice_from_headers():
321321
'accept') == 'application/ld+json'
322322
assert util.get_choice_from_headers(
323323
{'accept-language': 'en_US', 'accept': '*/*'}, 'accept') == '*/*'
324+
325+
326+
@pytest.mark.parametrize('url,allow_internal,result', [
327+
['http://127.0.0.1/test', False, False],
328+
['http://127.0.0.1/test', True, True],
329+
['http://192.168.0.12/test', False, False],
330+
['http://192.168.0.12/test', True, True],
331+
['http://169.254.0.11/test', False, False],
332+
['http://169.254.0.11/test', True, True],
333+
['http://0.0.0.0/test', True, True],
334+
['http://0.0.0.0/test', False, False],
335+
['http://localhost:5000/test', False, False],
336+
['http://localhost:5000/test', True, True],
337+
['https://pygeoapi.io', False, True],
338+
['https://pygeoapi.io', True, True]
339+
])
340+
def test_is_request_allowed(url, allow_internal, result):
341+
assert util.is_request_allowed(url, allow_internal) is result

0 commit comments

Comments
 (0)