Skip to content

Commit b1c54a3

Browse files
snambergithub-advanced-security[bot]lukasbindreiter
authored
Fix token required check for api.tilebox.com (#1)
* Potential fix for code scanning alert no. 1: Incomplete URL substring sanitization Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Fix token required check for api.tilebox.com * Restrict permissions of github workflow * Fix aio channel --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Lukas Bindreiter <lukas.bindreiter@tilebox.com>
1 parent 6429c60 commit b1c54a3

10 files changed

Lines changed: 355 additions & 225 deletions

File tree

.github/workflows/main.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
name: CI
22

3+
permissions: {}
4+
35
on:
46
push:
57
branches: [main]

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- `tilebox-grpc`: More robust parsing of GRPC channel URLs.
13+
1014
## [0.37.0] - 2025-06-06
1115

1216
### Changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ area_of_interest = shape({
7171
)
7272
s2a_l1c = sentinel2_msi.collection("S2A_S2MSI1C")
7373
results = s2a_l1c.query(
74-
temporal_extent=("2022-07-13", "2022-07-13T02:00"),
74+
temporal_extent=("2025-03-01", "2025-06-01"),
7575
spatial_extent=area_of_interest,
7676
show_progress=True
7777
)

tilebox-datasets/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ Query data:
6666
```python
6767
s2a_l1c = sentinel2_msi.collection("S2A_S2MSI1C")
6868
results = s2a_l1c.query(
69-
temporal_extent=("2017-01-01", "2023-01-01"),
69+
temporal_extent=("2025-03-01", "2025-06-01"),
7070
show_progress=True
7171
)
7272
print(f"Found {results.sizes['time']} datapoints") # Found 220542 datapoints
@@ -83,7 +83,7 @@ area_of_interest = shape({
8383
)
8484
s2a_l1c = sentinel2_msi.collection("S2A_S2MSI1C")
8585
results = s2a_l1c.query(
86-
temporal_extent=("2022-07-13", "2022-07-13T02:00"),
86+
temporal_extent=("2025-03-01", "2025-06-01"),
8787
spatial_extent=area_of_interest,
8888
show_progress=True
8989
)

tilebox-datasets/tilebox/datasets/client.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from loguru import logger
77
from promise import Promise
88

9+
from _tilebox.grpc.channel import parse_channel_info
910
from tilebox.datasets.data.datasets import Dataset, DatasetGroup, ListDatasetsResponse
1011
from tilebox.datasets.data.uuid import as_uuid
1112
from tilebox.datasets.group import Group
@@ -66,11 +67,13 @@ def _dataset_by_id(self, dataset_id: str | UUID, dataset_type: type[T]) -> Promi
6667
def token_from_env(url: str, token: str | None) -> str | None:
6768
if token is None: # if no token is provided, try to get it from the environment
6869
token = os.environ.get("TILEBOX_API_KEY", None)
69-
if "api.tilebox.com" in url and token is None:
70+
71+
if token is None and parse_channel_info(url).address == "api.tilebox.com":
7072
raise ValueError(
7173
"No API key provided and no TILEBOX_API_KEY environment variable set. Please specify an API key using "
7274
"the token argument. For example: `Client(token='YOUR_TILEBOX_API_KEY')`"
7375
)
76+
7477
return token
7578

7679

tilebox-grpc/_tilebox/grpc/aio/channel.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from collections.abc import Callable, Sequence
22
from typing import TypeVar
33

4-
from _tilebox.grpc.channel import CHANNEL_OPTIONS, ChannelInfo, add_metadata, parse_channel_info
5-
from grpc import ssl_channel_credentials
4+
from _tilebox.grpc.channel import CHANNEL_OPTIONS, ChannelInfo, ChannelProtocol, add_metadata, parse_channel_info
5+
from grpc import Compression, ssl_channel_credentials
66
from grpc.aio import (
77
Channel,
88
ClientCallDetails,
@@ -34,11 +34,28 @@ def open_channel(url: str, auth_token: str | None = None) -> Channel:
3434

3535

3636
def _open_channel(channel_info: ChannelInfo, interceptors: Sequence[ClientInterceptor]) -> Channel:
37-
if channel_info.use_ssl:
38-
return secure_channel(
39-
channel_info.url_without_protocol, ssl_channel_credentials(), CHANNEL_OPTIONS, interceptors=interceptors
40-
)
41-
return insecure_channel(channel_info.url_without_protocol, CHANNEL_OPTIONS, interceptors=interceptors)
37+
match channel_info.protocol:
38+
case ChannelProtocol.HTTPS:
39+
return secure_channel(
40+
f"{channel_info.address}:{channel_info.port}",
41+
ssl_channel_credentials(),
42+
CHANNEL_OPTIONS,
43+
compression=Compression.Gzip,
44+
interceptors=interceptors,
45+
)
46+
case ChannelProtocol.HTTP:
47+
return insecure_channel(
48+
f"{channel_info.address}:{channel_info.port}",
49+
CHANNEL_OPTIONS,
50+
compression=Compression.NoCompression,
51+
interceptors=interceptors,
52+
)
53+
case ChannelProtocol.UNIX:
54+
return insecure_channel(
55+
channel_info.address, CHANNEL_OPTIONS, compression=Compression.NoCompression, interceptors=interceptors
56+
)
57+
case _:
58+
raise ValueError(f"Unsupported channel protocol: {channel_info.protocol}")
4259

4360

4461
RequestType = TypeVar("RequestType")

tilebox-grpc/_tilebox/grpc/channel.py

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import re
33
from collections.abc import Callable
44
from dataclasses import dataclass
5+
from enum import Enum
56
from typing import TypeVar
67

78
from grpc import (
@@ -43,10 +44,21 @@
4344
]
4445

4546

47+
class ChannelProtocol(Enum):
48+
HTTPS = 1
49+
HTTP = 2
50+
UNIX = 3
51+
52+
4653
@dataclass
4754
class ChannelInfo:
48-
url_without_protocol: str
49-
use_ssl: bool
55+
address: str
56+
"""GRPC target address. For http(s) connections this is the host[:port] format without a scheme. For unix sockets
57+
this is the unix socket path including the `unix://` prefix for absolute paths or `unix:` for relative paths."""
58+
port: int
59+
"""Port number for http(s) connections. For unix sockets this is always 0."""
60+
protocol: ChannelProtocol
61+
"""The protocol to use for the channel."""
5062

5163

5264
def open_channel(url: str, auth_token: str | None = None) -> Channel:
@@ -69,14 +81,22 @@ def open_channel(url: str, auth_token: str | None = None) -> Channel:
6981

7082

7183
def _open_channel(channel_info: ChannelInfo) -> Channel:
72-
if channel_info.use_ssl:
73-
return secure_channel(
74-
channel_info.url_without_protocol,
75-
ssl_channel_credentials(),
76-
CHANNEL_OPTIONS,
77-
compression=Compression.Gzip,
78-
)
79-
return insecure_channel(channel_info.url_without_protocol, CHANNEL_OPTIONS, compression=Compression.NoCompression)
84+
match channel_info.protocol:
85+
case ChannelProtocol.HTTPS:
86+
return secure_channel(
87+
f"{channel_info.address}:{channel_info.port}",
88+
ssl_channel_credentials(),
89+
CHANNEL_OPTIONS,
90+
compression=Compression.Gzip,
91+
)
92+
case ChannelProtocol.HTTP:
93+
return insecure_channel(
94+
f"{channel_info.address}:{channel_info.port}", CHANNEL_OPTIONS, compression=Compression.NoCompression
95+
)
96+
case ChannelProtocol.UNIX:
97+
return insecure_channel(channel_info.address, CHANNEL_OPTIONS, compression=Compression.NoCompression)
98+
case _:
99+
raise ValueError(f"Unsupported channel protocol: {channel_info.protocol}")
80100

81101

82102
_URL_SCHEME = re.compile(r"^(https?://)?([^: ]+)(:\d+)?/?$")
@@ -98,27 +118,28 @@ def parse_channel_info(url: str) -> ChannelInfo:
98118
A ChannelInfo object that can be used to create a gRPC channel.
99119
"""
100120
# See https://github.com/grpc/grpc/blob/master/doc/naming.md
101-
if url.startswith("unix:"):
102-
return ChannelInfo(url, False)
121+
if url.startswith("unix:"): ## unix:///absolute/path or unix://path
122+
return ChannelInfo(url, 0, ChannelProtocol.UNIX)
103123

104124
# `urllib.parse.urlparse` behaves a bit weird with URLs that don't have a scheme but a port number, so regex it is
105125
if (match := _URL_SCHEME.match(url)) is None:
106126
raise ValueError(f"Invalid URL: {url}")
107127
scheme, netloc, port = match.groups()
108128
netloc = netloc.rstrip("/")
109-
use_ssl = True
129+
protocol = ChannelProtocol.HTTPS
110130

111131
if scheme == "http://": # explicitly set http -> require a port
112132
if port is None:
113133
raise ValueError("Explicit port required for insecure HTTP channel")
114-
use_ssl = False
134+
protocol = ChannelProtocol.HTTP
135+
136+
# no scheme, but a port that looks like a dev port -> insecure
137+
if scheme is None and port is not None and port != ":443":
138+
protocol = ChannelProtocol.HTTP
115139

116-
if scheme is None and port is not None: # no scheme, but a port that looks like a dev port -> insecure
117-
use_ssl = port == ":443"
140+
port_number = 443 if port is None else int(port.removeprefix(":"))
118141

119-
if use_ssl:
120-
return ChannelInfo(netloc + (port or ":443"), True)
121-
return ChannelInfo(netloc + port, False)
142+
return ChannelInfo(netloc, port_number, protocol)
122143

123144

124145
RequestType = TypeVar("RequestType")

tilebox-grpc/tests/test_channel.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from _tilebox.grpc.channel import (
66
CHANNEL_OPTIONS,
7+
ChannelProtocol,
78
open_channel,
89
parse_channel_info,
910
)
@@ -50,24 +51,26 @@ def test_open_authenticated_channel(open_func: MagicMock, intercept_func: MagicM
5051
)
5152
def test_parse_channel_info_secure(url: str) -> None:
5253
channel_info = parse_channel_info(url)
53-
assert channel_info.url_without_protocol == "api.tilebox.com:443"
54-
assert channel_info.use_ssl
54+
assert channel_info.address == "api.tilebox.com"
55+
assert channel_info.port == 443
56+
assert channel_info.protocol == ChannelProtocol.HTTPS
5557

5658

5759
@pytest.mark.parametrize(
5860
("url", "expected_url_without_protocol"),
5961
[
60-
("0.0.0.0:8083", "0.0.0.0:8083"),
61-
("http://0.0.0.0:8083", "0.0.0.0:8083"),
62-
("http://localhost:8083", "localhost:8083"),
63-
("localhost:8083", "localhost:8083"),
64-
("http://some.insecure.url:1234", "some.insecure.url:1234"),
62+
("0.0.0.0:8083", "0.0.0.0"), # noqa: S104
63+
("http://0.0.0.0:8083", "0.0.0.0"), # noqa: S104
64+
("http://localhost:8083", "localhost"),
65+
("localhost:8083", "localhost"),
66+
("http://some.insecure.url:8083", "some.insecure.url"),
6567
],
6668
)
6769
def test_parse_channel_info_insecure(url: str, expected_url_without_protocol: str) -> None:
6870
channel_info = parse_channel_info(url)
69-
assert channel_info.url_without_protocol == expected_url_without_protocol
70-
assert not channel_info.use_ssl
71+
assert channel_info.address == expected_url_without_protocol
72+
assert channel_info.port == 8083
73+
assert channel_info.protocol == ChannelProtocol.HTTP
7174

7275

7376
@pytest.mark.parametrize(
@@ -79,8 +82,9 @@ def test_parse_channel_info_insecure(url: str, expected_url_without_protocol: st
7982
)
8083
def test_parse_channel_info_unix(url: str) -> None:
8184
channel_info = parse_channel_info(url)
82-
assert channel_info.url_without_protocol == url
83-
assert not channel_info.use_ssl
85+
assert channel_info.address == url
86+
assert channel_info.port == 0
87+
assert channel_info.protocol == ChannelProtocol.UNIX
8488

8589

8690
def test_parse_channel_invalid() -> None:

tilebox-workflows/tilebox/workflows/client.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22
import os
33

4-
from _tilebox.grpc.channel import open_channel
4+
from _tilebox.grpc.channel import open_channel, parse_channel_info
55
from tilebox.datasets.sync.client import Client as DatasetsClient
66
from tilebox.workflows.automations.client import AutomationClient, AutomationService
77
from tilebox.workflows.cache import JobCache, NoCache
@@ -28,13 +28,7 @@ def __init__(self, *, url: str = "https://api.tilebox.com", token: str | None =
2828
url: Tilebox API Url. Defaults to "https://api.tilebox.com".
2929
token: The API Key to authenticate with. If not set the `TILEBOX_API_KEY` environment variable will be used.
3030
"""
31-
if token is None: # if no token is provided, try to get it from the environment
32-
token = os.environ.get("TILEBOX_API_KEY", None)
33-
if url == "https://api.tilebox.com" and token is None:
34-
raise ValueError(
35-
"No API key provided and no TILEBOX_API_KEY environment variable set. Please specify an API key using "
36-
"the token argument. For example: `Client(token='YOUR_TILEBOX_API_KEY')`"
37-
)
31+
token = _token_from_env(url, token)
3832
self._auth = {"token": token, "url": url}
3933
self._channel = open_channel(url, token)
4034

@@ -147,3 +141,16 @@ def automations(self) -> AutomationClient:
147141
A client for the automations service.
148142
"""
149143
return AutomationClient(AutomationService(self._channel))
144+
145+
146+
def _token_from_env(url: str, token: str | None) -> str | None:
147+
if token is None: # if no token is provided, try to get it from the environment
148+
token = os.environ.get("TILEBOX_API_KEY", None)
149+
150+
if token is None and parse_channel_info(url).address == "api.tilebox.com":
151+
raise ValueError(
152+
"No API key provided and no TILEBOX_API_KEY environment variable set. Please specify an API key using "
153+
"the token argument. For example: `Client(token='YOUR_TILEBOX_API_KEY')`"
154+
)
155+
156+
return token

0 commit comments

Comments
 (0)