Skip to content

Commit 3a63f5b

Browse files
committed
OAProc: secure subscriber URLs in requests
1 parent bf25b86 commit 3a63f5b

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,
@@ -395,13 +401,13 @@ def execute_process(
395401
"""
396402

397403
job_id = str(uuid.uuid1())
398-
processor = self.get_processor(process_id)
399-
processor.set_job_id(job_id)
404+
self.processor = self.get_processor(process_id)
405+
self.processor.set_job_id(job_id)
400406
extra_execute_handler_parameters = {
401407
'requested_response': requested_response
402408
}
403409

404-
job_control_options = processor.metadata.get(
410+
job_control_options = self.processor.metadata.get(
405411
'jobControlOptions', [])
406412

407413
if execution_mode == RequestedProcessExecutionMode.respond_async:
@@ -474,7 +480,7 @@ def execute_process(
474480
# TODO: handler's response could also be allowed to include more HTTP
475481
# headers
476482
mime_type, outputs, status = handler(
477-
processor,
483+
self.processor,
478484
job_id,
479485
data_dict,
480486
requested_outputs,
@@ -484,26 +490,37 @@ def execute_process(
484490

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

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

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

508525
def __repr__(self):
509526
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
@@ -755,3 +757,30 @@ def remove_url_auth(url: str) -> str:
755757
u = urlparse(url)
756758
auth = f'{u.username}:{u.password}@'
757759
return url.replace(auth, '')
760+
761+
762+
def is_request_allowed(url: str, allow_internal: bool = False) -> bool:
763+
"""
764+
Test whether an HTTP request is allowed to be executed
765+
766+
:param url: `str` of URL
767+
:param allow_internal: `bool` of whether internal requests are
768+
allowed (default `False`)
769+
770+
:returns: `bool` of whether HTTP request execution is allowed
771+
"""
772+
773+
is_allowed = False
774+
775+
u = urlparse(url)
776+
777+
ip = socket.gethostbyname(u.hostname)
778+
779+
is_private = ipaddress.ip_address(ip).is_private
780+
781+
if not is_private:
782+
is_allowed = True
783+
if is_private and allow_internal:
784+
is_allowed = True
785+
786+
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
@@ -329,3 +329,21 @@ def test_get_choice_from_headers():
329329
'accept') == 'application/ld+json'
330330
assert util.get_choice_from_headers(
331331
{'accept-language': 'en_US', 'accept': '*/*'}, 'accept') == '*/*'
332+
333+
334+
@pytest.mark.parametrize('url,allow_internal,result', [
335+
['http://127.0.0.1/test', False, False],
336+
['http://127.0.0.1/test', True, True],
337+
['http://192.168.0.12/test', False, False],
338+
['http://192.168.0.12/test', True, True],
339+
['http://169.254.0.11/test', False, False],
340+
['http://169.254.0.11/test', True, True],
341+
['http://0.0.0.0/test', True, True],
342+
['http://0.0.0.0/test', False, False],
343+
['http://localhost:5000/test', False, False],
344+
['http://localhost:5000/test', True, True],
345+
['https://pygeoapi.io', False, True],
346+
['https://pygeoapi.io', True, True]
347+
])
348+
def test_is_request_allowed(url, allow_internal, result):
349+
assert util.is_request_allowed(url, allow_internal) is result

0 commit comments

Comments
 (0)