otdf-python 0.4.2__py3-none-any.whl → 0.4.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- otdf_python/cli.py +50 -21
- otdf_python/kas_allowlist.py +182 -0
- otdf_python/kas_client.py +26 -2
- otdf_python/nanotdf.py +4 -6
- otdf_python/sdk.py +30 -0
- otdf_python/sdk_builder.py +86 -2
- otdf_python/tdf.py +2 -2
- {otdf_python-0.4.2.dist-info → otdf_python-0.4.3.dist-info}/METADATA +1 -1
- {otdf_python-0.4.2.dist-info → otdf_python-0.4.3.dist-info}/RECORD +11 -10
- {otdf_python-0.4.2.dist-info → otdf_python-0.4.3.dist-info}/WHEEL +0 -0
- {otdf_python-0.4.2.dist-info → otdf_python-0.4.3.dist-info}/licenses/LICENSE +0 -0
otdf_python/cli.py
CHANGED
|
@@ -112,33 +112,14 @@ def load_client_credentials(creds_file_path: str) -> tuple[str, str]:
|
|
|
112
112
|
) from e
|
|
113
113
|
|
|
114
114
|
|
|
115
|
-
def
|
|
116
|
-
"""
|
|
117
|
-
builder = SDKBuilder()
|
|
118
|
-
|
|
119
|
-
if args.platform_url:
|
|
120
|
-
builder.set_platform_endpoint(args.platform_url)
|
|
121
|
-
|
|
122
|
-
# Auto-detect HTTP URLs and enable plaintext mode
|
|
123
|
-
if args.platform_url.startswith("http://") and (
|
|
124
|
-
not hasattr(args, "plaintext") or not args.plaintext
|
|
125
|
-
):
|
|
126
|
-
logger.debug(
|
|
127
|
-
f"Auto-detected HTTP URL {args.platform_url}, enabling plaintext mode"
|
|
128
|
-
)
|
|
129
|
-
builder.use_insecure_plaintext_connection(True)
|
|
130
|
-
|
|
131
|
-
if args.oidc_endpoint:
|
|
132
|
-
builder.set_issuer_endpoint(args.oidc_endpoint)
|
|
133
|
-
|
|
115
|
+
def _configure_auth(builder: SDKBuilder, args) -> None:
|
|
116
|
+
"""Configure authentication on the SDK builder."""
|
|
134
117
|
if args.client_id and args.client_secret:
|
|
135
118
|
builder.client_secret(args.client_id, args.client_secret)
|
|
136
119
|
elif hasattr(args, "with_client_creds_file") and args.with_client_creds_file:
|
|
137
|
-
# Load credentials from file
|
|
138
120
|
client_id, client_secret = load_client_credentials(args.with_client_creds_file)
|
|
139
121
|
builder.client_secret(client_id, client_secret)
|
|
140
122
|
elif hasattr(args, "auth") and args.auth:
|
|
141
|
-
# Parse combined auth string (clientId:clientSecret) - legacy support
|
|
142
123
|
auth_parts = args.auth.split(":")
|
|
143
124
|
if len(auth_parts) != 2:
|
|
144
125
|
raise CLIError(
|
|
@@ -152,12 +133,49 @@ def build_sdk(args) -> SDK:
|
|
|
152
133
|
"Authentication required: provide --with-client-creds-file OR --client-id and --client-secret",
|
|
153
134
|
)
|
|
154
135
|
|
|
136
|
+
|
|
137
|
+
def _configure_kas_allowlist(builder: SDKBuilder, args) -> None:
|
|
138
|
+
"""Configure KAS allowlist on the SDK builder."""
|
|
139
|
+
if hasattr(args, "ignore_kas_allowlist") and args.ignore_kas_allowlist:
|
|
140
|
+
logger.warning(
|
|
141
|
+
"KAS allowlist validation is disabled. This may leak credentials "
|
|
142
|
+
"to malicious servers if decrypting untrusted TDF files."
|
|
143
|
+
)
|
|
144
|
+
builder.with_ignore_kas_allowlist(True)
|
|
145
|
+
elif hasattr(args, "kas_allowlist") and args.kas_allowlist:
|
|
146
|
+
kas_urls = [url.strip() for url in args.kas_allowlist.split(",") if url.strip()]
|
|
147
|
+
logger.debug(f"Using KAS allowlist: {kas_urls}")
|
|
148
|
+
builder.with_kas_allowlist(kas_urls)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def build_sdk(args) -> SDK:
|
|
152
|
+
"""Build SDK instance from CLI arguments."""
|
|
153
|
+
builder = SDKBuilder()
|
|
154
|
+
|
|
155
|
+
if args.platform_url:
|
|
156
|
+
builder.set_platform_endpoint(args.platform_url)
|
|
157
|
+
# Auto-detect HTTP URLs and enable plaintext mode
|
|
158
|
+
if args.platform_url.startswith("http://") and (
|
|
159
|
+
not hasattr(args, "plaintext") or not args.plaintext
|
|
160
|
+
):
|
|
161
|
+
logger.debug(
|
|
162
|
+
f"Auto-detected HTTP URL {args.platform_url}, enabling plaintext mode"
|
|
163
|
+
)
|
|
164
|
+
builder.use_insecure_plaintext_connection(True)
|
|
165
|
+
|
|
166
|
+
if args.oidc_endpoint:
|
|
167
|
+
builder.set_issuer_endpoint(args.oidc_endpoint)
|
|
168
|
+
|
|
169
|
+
_configure_auth(builder, args)
|
|
170
|
+
|
|
155
171
|
if hasattr(args, "plaintext") and args.plaintext:
|
|
156
172
|
builder.use_insecure_plaintext_connection(True)
|
|
157
173
|
|
|
158
174
|
if args.insecure:
|
|
159
175
|
builder.use_insecure_skip_verify(True)
|
|
160
176
|
|
|
177
|
+
_configure_kas_allowlist(builder, args)
|
|
178
|
+
|
|
161
179
|
return builder.build()
|
|
162
180
|
|
|
163
181
|
|
|
@@ -476,6 +494,17 @@ Where creds.json contains:
|
|
|
476
494
|
security_group.add_argument(
|
|
477
495
|
"--insecure", action="store_true", help="Skip TLS verification"
|
|
478
496
|
)
|
|
497
|
+
security_group.add_argument(
|
|
498
|
+
"--kas-allowlist",
|
|
499
|
+
help="Comma-separated list of trusted KAS URLs. "
|
|
500
|
+
"By default, only the platform URL's KAS endpoint is trusted.",
|
|
501
|
+
)
|
|
502
|
+
security_group.add_argument(
|
|
503
|
+
"--ignore-kas-allowlist",
|
|
504
|
+
action="store_true",
|
|
505
|
+
help="WARNING: Disable KAS allowlist validation. This is insecure and "
|
|
506
|
+
"should only be used for testing. May leak credentials to malicious servers.",
|
|
507
|
+
)
|
|
479
508
|
|
|
480
509
|
# Subcommands
|
|
481
510
|
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""KAS Allowlist: Validates KAS URLs against a list of trusted hosts.
|
|
2
|
+
|
|
3
|
+
This module provides protection against SSRF attacks where malicious TDF files
|
|
4
|
+
could contain attacker-controlled KAS URLs to steal OIDC credentials.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class KASAllowlist:
|
|
12
|
+
"""Validates KAS URLs against an allowlist of trusted hosts.
|
|
13
|
+
|
|
14
|
+
This class prevents credential theft by ensuring the SDK only sends
|
|
15
|
+
authentication tokens to trusted KAS endpoints.
|
|
16
|
+
|
|
17
|
+
Example:
|
|
18
|
+
allowlist = KASAllowlist(["https://kas.example.com"])
|
|
19
|
+
allowlist.is_allowed("https://kas.example.com/kas") # True
|
|
20
|
+
allowlist.is_allowed("https://evil.com/kas") # False
|
|
21
|
+
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, allowed_urls: list[str] | None = None, allow_all: bool = False):
|
|
25
|
+
"""Initialize the KAS allowlist.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
allowed_urls: List of trusted KAS URLs. Each URL is normalized to
|
|
29
|
+
its origin (scheme://host:port) for comparison.
|
|
30
|
+
allow_all: If True, all URLs are allowed. Use only for testing.
|
|
31
|
+
A warning is logged when this is enabled.
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
self._allowed_origins: set[str] = set()
|
|
35
|
+
self._allow_all = allow_all
|
|
36
|
+
|
|
37
|
+
if allow_all:
|
|
38
|
+
logging.warning(
|
|
39
|
+
"KAS allowlist is disabled (allow_all=True). "
|
|
40
|
+
"This is insecure and should only be used for testing."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if allowed_urls:
|
|
44
|
+
for url in allowed_urls:
|
|
45
|
+
self.add(url)
|
|
46
|
+
|
|
47
|
+
def add(self, url: str) -> None:
|
|
48
|
+
"""Add a URL to the allowlist.
|
|
49
|
+
|
|
50
|
+
The URL is normalized to its origin (scheme://host:port) before storage.
|
|
51
|
+
Paths and query strings are stripped.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
url: The KAS URL to allow. Can include path components which
|
|
55
|
+
will be stripped for origin comparison.
|
|
56
|
+
|
|
57
|
+
"""
|
|
58
|
+
origin = self._get_origin(url)
|
|
59
|
+
self._allowed_origins.add(origin)
|
|
60
|
+
logging.debug(f"Added KAS origin to allowlist: {origin}")
|
|
61
|
+
|
|
62
|
+
def is_allowed(self, url: str) -> bool:
|
|
63
|
+
"""Check if a URL is allowed by the allowlist.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
url: The KAS URL to check.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
True if the URL's origin is in the allowlist or allow_all is True.
|
|
70
|
+
False otherwise.
|
|
71
|
+
|
|
72
|
+
"""
|
|
73
|
+
if self._allow_all:
|
|
74
|
+
logging.debug(f"KAS URL allowed (allow_all=True): {url}")
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
if not self._allowed_origins:
|
|
78
|
+
logging.debug(f"KAS URL rejected (empty allowlist): {url}")
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
origin = self._get_origin(url)
|
|
82
|
+
allowed = origin in self._allowed_origins
|
|
83
|
+
if allowed:
|
|
84
|
+
logging.debug(f"KAS URL allowed: {url} (origin: {origin})")
|
|
85
|
+
else:
|
|
86
|
+
logging.debug(
|
|
87
|
+
f"KAS URL rejected: {url} (origin: {origin}, "
|
|
88
|
+
f"allowed: {self._allowed_origins})"
|
|
89
|
+
)
|
|
90
|
+
return allowed
|
|
91
|
+
|
|
92
|
+
def validate(self, url: str) -> None:
|
|
93
|
+
"""Validate a URL against the allowlist, raising an exception if not allowed.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
url: The KAS URL to validate.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
SDK.KasAllowlistException: If the URL is not in the allowlist.
|
|
100
|
+
|
|
101
|
+
"""
|
|
102
|
+
if not self.is_allowed(url):
|
|
103
|
+
# Import here to avoid circular imports
|
|
104
|
+
from .sdk import SDK
|
|
105
|
+
|
|
106
|
+
raise SDK.KasAllowlistException(url, self._allowed_origins)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def allowed_origins(self) -> set[str]:
|
|
110
|
+
"""Return the set of allowed origins (read-only copy)."""
|
|
111
|
+
return self._allowed_origins.copy()
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def allow_all(self) -> bool:
|
|
115
|
+
"""Return whether all URLs are allowed."""
|
|
116
|
+
return self._allow_all
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _get_origin(url: str) -> str:
|
|
120
|
+
"""Extract the origin (scheme://host:port) from a URL.
|
|
121
|
+
|
|
122
|
+
This normalizes URLs for comparison by stripping paths and query strings.
|
|
123
|
+
Default ports (80 for http, 443 for https) are included explicitly.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
url: The URL to extract the origin from.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Normalized origin string in format scheme://host:port
|
|
130
|
+
|
|
131
|
+
"""
|
|
132
|
+
# Add scheme if missing
|
|
133
|
+
if "://" not in url:
|
|
134
|
+
url = "https://" + url
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
parsed = urlparse(url)
|
|
138
|
+
except Exception as e:
|
|
139
|
+
logging.warning(f"Failed to parse URL {url}: {e}")
|
|
140
|
+
# Return the URL as-is if parsing fails
|
|
141
|
+
return url.lower()
|
|
142
|
+
|
|
143
|
+
scheme = (parsed.scheme or "https").lower()
|
|
144
|
+
hostname = (parsed.hostname or "").lower()
|
|
145
|
+
|
|
146
|
+
if not hostname:
|
|
147
|
+
# URL might be malformed, return as-is
|
|
148
|
+
logging.warning(f"Could not extract hostname from URL: {url}")
|
|
149
|
+
return url.lower()
|
|
150
|
+
|
|
151
|
+
# Determine port (use explicit port or default for scheme)
|
|
152
|
+
if parsed.port:
|
|
153
|
+
port = parsed.port
|
|
154
|
+
elif scheme == "http":
|
|
155
|
+
port = 80
|
|
156
|
+
else:
|
|
157
|
+
port = 443
|
|
158
|
+
|
|
159
|
+
return f"{scheme}://{hostname}:{port}"
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def from_platform_url(cls, platform_url: str) -> "KASAllowlist":
|
|
163
|
+
"""Create an allowlist from a platform URL.
|
|
164
|
+
|
|
165
|
+
This is the default behavior: auto-allow the platform's KAS endpoint.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
platform_url: The OpenTDF platform URL. The KAS endpoint is
|
|
169
|
+
assumed to be at {platform_url}/kas.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
KASAllowlist configured to allow the platform's KAS endpoint.
|
|
173
|
+
|
|
174
|
+
"""
|
|
175
|
+
allowlist = cls()
|
|
176
|
+
# Add the platform URL itself (KAS might be at root or /kas)
|
|
177
|
+
allowlist.add(platform_url)
|
|
178
|
+
# Also construct the /kas endpoint explicitly
|
|
179
|
+
kas_url = platform_url.rstrip("/") + "/kas"
|
|
180
|
+
allowlist.add(kas_url)
|
|
181
|
+
logging.info(f"Created KAS allowlist from platform URL: {platform_url}")
|
|
182
|
+
return allowlist
|
otdf_python/kas_client.py
CHANGED
|
@@ -38,13 +38,26 @@ class KASClient:
|
|
|
38
38
|
cache=None,
|
|
39
39
|
use_plaintext=False,
|
|
40
40
|
verify_ssl=True,
|
|
41
|
+
kas_allowlist=None,
|
|
41
42
|
):
|
|
42
|
-
"""Initialize KAS client.
|
|
43
|
+
"""Initialize KAS client.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
kas_url: Default KAS URL
|
|
47
|
+
token_source: Function that returns an authentication token
|
|
48
|
+
cache: Optional KASKeyCache for caching public keys
|
|
49
|
+
use_plaintext: Whether to use HTTP instead of HTTPS
|
|
50
|
+
verify_ssl: Whether to verify SSL certificates
|
|
51
|
+
kas_allowlist: Optional KASAllowlist for URL validation. If provided,
|
|
52
|
+
only URLs in the allowlist will be contacted.
|
|
53
|
+
|
|
54
|
+
"""
|
|
43
55
|
self.kas_url = kas_url
|
|
44
56
|
self.token_source = token_source
|
|
45
57
|
self.cache = cache or KASKeyCache()
|
|
46
58
|
self.use_plaintext = use_plaintext
|
|
47
59
|
self.verify_ssl = verify_ssl
|
|
60
|
+
self.kas_allowlist = kas_allowlist
|
|
48
61
|
self.decryptor = None
|
|
49
62
|
self.client_public_key = None
|
|
50
63
|
|
|
@@ -86,15 +99,26 @@ class KASClient:
|
|
|
86
99
|
def _normalize_kas_url(self, url: str) -> str:
|
|
87
100
|
"""Normalize KAS URLs based on client security settings.
|
|
88
101
|
|
|
102
|
+
This method also validates the URL against the KAS allowlist if one
|
|
103
|
+
is configured. This prevents SSRF attacks where malicious TDF files
|
|
104
|
+
could contain attacker-controlled KAS URLs to steal OIDC credentials.
|
|
105
|
+
|
|
89
106
|
Args:
|
|
90
107
|
url: The KAS URL to normalize
|
|
91
108
|
|
|
92
109
|
Returns:
|
|
93
110
|
Normalized URL with appropriate protocol and port
|
|
94
111
|
|
|
112
|
+
Raises:
|
|
113
|
+
KASAllowlistException: If the URL is not in the allowlist
|
|
114
|
+
|
|
95
115
|
"""
|
|
96
116
|
from urllib.parse import urlparse
|
|
97
117
|
|
|
118
|
+
# Validate against allowlist BEFORE making any requests
|
|
119
|
+
if self.kas_allowlist is not None:
|
|
120
|
+
self.kas_allowlist.validate(url)
|
|
121
|
+
|
|
98
122
|
try:
|
|
99
123
|
# Parse the URL
|
|
100
124
|
parsed = urlparse(url)
|
|
@@ -154,7 +178,7 @@ class KASClient:
|
|
|
154
178
|
# Reconstruct URL preserving the path (especially /kas prefix)
|
|
155
179
|
try:
|
|
156
180
|
# Create URL preserving the path component for proper endpoint routing
|
|
157
|
-
path = parsed.path
|
|
181
|
+
path = parsed.path or ""
|
|
158
182
|
normalized_url = f"{scheme}://{parsed.hostname}:{port}{path}"
|
|
159
183
|
logging.debug(f"normalized url [{parsed.geturl()}] to [{normalized_url}]")
|
|
160
184
|
return normalized_url
|
otdf_python/nanotdf.py
CHANGED
|
@@ -70,8 +70,8 @@ class NanoTDF:
|
|
|
70
70
|
if isinstance(obj, PolicyBody):
|
|
71
71
|
# Convert data_attributes to dataAttributes and use null instead of empty array
|
|
72
72
|
result = {
|
|
73
|
-
"dataAttributes": obj.data_attributes
|
|
74
|
-
"dissem": obj.dissem
|
|
73
|
+
"dataAttributes": obj.data_attributes or None,
|
|
74
|
+
"dissem": obj.dissem or None,
|
|
75
75
|
}
|
|
76
76
|
return result
|
|
77
77
|
elif isinstance(obj, AttributeObject):
|
|
@@ -115,14 +115,12 @@ class NanoTDF:
|
|
|
115
115
|
tuple: (policy_body, policy_type)
|
|
116
116
|
|
|
117
117
|
"""
|
|
118
|
-
attributes = config.attributes
|
|
118
|
+
attributes = config.attributes or []
|
|
119
119
|
policy_object = self._create_policy_object(attributes)
|
|
120
120
|
policy_json = json.dumps(
|
|
121
121
|
policy_object, default=self._serialize_policy_object
|
|
122
122
|
).encode("utf-8")
|
|
123
|
-
policy_type =
|
|
124
|
-
config.policy_type if config.policy_type else "EMBEDDED_POLICY_PLAIN_TEXT"
|
|
125
|
-
)
|
|
123
|
+
policy_type = config.policy_type or "EMBEDDED_POLICY_PLAIN_TEXT"
|
|
126
124
|
|
|
127
125
|
if policy_type == "EMBEDDED_POLICY_PLAIN_TEXT":
|
|
128
126
|
policy_body = policy_json
|
otdf_python/sdk.py
CHANGED
|
@@ -37,6 +37,7 @@ class KAS(AbstractContextManager):
|
|
|
37
37
|
token_source=None,
|
|
38
38
|
sdk_ssl_verify=True,
|
|
39
39
|
use_plaintext=False,
|
|
40
|
+
kas_allowlist=None,
|
|
40
41
|
):
|
|
41
42
|
"""Initialize the KAS client.
|
|
42
43
|
|
|
@@ -45,6 +46,7 @@ class KAS(AbstractContextManager):
|
|
|
45
46
|
token_source: Function that returns an authentication token
|
|
46
47
|
sdk_ssl_verify: Whether to verify SSL certificates
|
|
47
48
|
use_plaintext: Whether to use plaintext HTTP connections instead of HTTPS
|
|
49
|
+
kas_allowlist: Optional KASAllowlist for URL validation
|
|
48
50
|
|
|
49
51
|
"""
|
|
50
52
|
from .kas_client import KASClient
|
|
@@ -54,6 +56,7 @@ class KAS(AbstractContextManager):
|
|
|
54
56
|
token_source=token_source,
|
|
55
57
|
verify_ssl=sdk_ssl_verify,
|
|
56
58
|
use_plaintext=use_plaintext,
|
|
59
|
+
kas_allowlist=kas_allowlist,
|
|
57
60
|
)
|
|
58
61
|
# Store the parameters for potential use
|
|
59
62
|
self._sdk_ssl_verify = sdk_ssl_verify
|
|
@@ -405,6 +408,33 @@ class SDK(AbstractContextManager):
|
|
|
405
408
|
class KasAllowlistException(SDKException):
|
|
406
409
|
"""Throw when KAS allowlist check fails."""
|
|
407
410
|
|
|
411
|
+
def __init__(
|
|
412
|
+
self,
|
|
413
|
+
url: str,
|
|
414
|
+
allowed_origins: set[str] | None = None,
|
|
415
|
+
message: str | None = None,
|
|
416
|
+
):
|
|
417
|
+
"""Initialize exception.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
url: The KAS URL that was rejected
|
|
421
|
+
allowed_origins: Set of allowed origin URLs
|
|
422
|
+
message: Optional custom message (auto-generated if not provided)
|
|
423
|
+
|
|
424
|
+
"""
|
|
425
|
+
self.url = url
|
|
426
|
+
self.allowed_origins = allowed_origins or set()
|
|
427
|
+
if message is None:
|
|
428
|
+
origins_str = (
|
|
429
|
+
", ".join(sorted(self.allowed_origins))
|
|
430
|
+
if self.allowed_origins
|
|
431
|
+
else "none"
|
|
432
|
+
)
|
|
433
|
+
message = (
|
|
434
|
+
f"KAS URL not in allowlist: {url}. Allowed origins: {origins_str}"
|
|
435
|
+
)
|
|
436
|
+
super().__init__(message)
|
|
437
|
+
|
|
408
438
|
class AssertionException(SDKException):
|
|
409
439
|
"""Throw when an assertion validation fails."""
|
|
410
440
|
|
otdf_python/sdk_builder.py
CHANGED
|
@@ -10,6 +10,7 @@ from pathlib import Path
|
|
|
10
10
|
|
|
11
11
|
import httpx
|
|
12
12
|
|
|
13
|
+
from otdf_python.kas_allowlist import KASAllowlist
|
|
13
14
|
from otdf_python.sdk import KAS, SDK
|
|
14
15
|
from otdf_python.sdk_exceptions import AutoConfigureException
|
|
15
16
|
|
|
@@ -47,6 +48,8 @@ class SDKBuilder:
|
|
|
47
48
|
self.ssl_context: ssl.SSLContext | None = None
|
|
48
49
|
self.auth_token: str | None = None
|
|
49
50
|
self.cert_paths: list[str] = []
|
|
51
|
+
self._kas_allowlist_urls: list[str] | None = None
|
|
52
|
+
self._ignore_kas_allowlist: bool = False
|
|
50
53
|
|
|
51
54
|
@staticmethod
|
|
52
55
|
def new_builder() -> "SDKBuilder":
|
|
@@ -201,6 +204,54 @@ class SDKBuilder:
|
|
|
201
204
|
self.auth_token = token
|
|
202
205
|
return self
|
|
203
206
|
|
|
207
|
+
def with_kas_allowlist(self, urls: list[str]) -> "SDKBuilder":
|
|
208
|
+
"""Set the KAS allowlist to restrict which KAS servers the SDK will contact.
|
|
209
|
+
|
|
210
|
+
This provides protection against SSRF attacks where malicious TDF files
|
|
211
|
+
could contain attacker-controlled KAS URLs to steal OIDC credentials.
|
|
212
|
+
|
|
213
|
+
By default (if no allowlist is set), only the platform's KAS endpoint
|
|
214
|
+
is allowed.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
urls: List of trusted KAS URLs. Each URL is normalized to its
|
|
218
|
+
origin (scheme://host:port) for comparison.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
self: The builder instance for chaining
|
|
222
|
+
|
|
223
|
+
Example:
|
|
224
|
+
builder.with_kas_allowlist([
|
|
225
|
+
"https://kas.example.com",
|
|
226
|
+
"https://kas2.example.com:8443"
|
|
227
|
+
])
|
|
228
|
+
|
|
229
|
+
"""
|
|
230
|
+
self._kas_allowlist_urls = urls
|
|
231
|
+
return self
|
|
232
|
+
|
|
233
|
+
def with_ignore_kas_allowlist(self, ignore: bool = True) -> "SDKBuilder":
|
|
234
|
+
"""Configure whether to skip KAS allowlist validation.
|
|
235
|
+
|
|
236
|
+
WARNING: This is insecure and should only be used for testing or
|
|
237
|
+
development. When enabled, the SDK will contact any KAS URL found
|
|
238
|
+
in TDF files, potentially leaking credentials to malicious servers.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
ignore: Whether to ignore the KAS allowlist (default: True)
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
self: The builder instance for chaining
|
|
245
|
+
|
|
246
|
+
"""
|
|
247
|
+
self._ignore_kas_allowlist = ignore
|
|
248
|
+
if ignore:
|
|
249
|
+
logger.warning(
|
|
250
|
+
"KAS allowlist validation is disabled. This is insecure and "
|
|
251
|
+
"should only be used for testing."
|
|
252
|
+
)
|
|
253
|
+
return self
|
|
254
|
+
|
|
204
255
|
def _discover_token_endpoint_from_platform(self) -> None:
|
|
205
256
|
"""Discover token endpoint using OpenTDF platform configuration.
|
|
206
257
|
|
|
@@ -356,6 +407,34 @@ class SDKBuilder:
|
|
|
356
407
|
f"Error during token acquisition: {e!s}"
|
|
357
408
|
) from e
|
|
358
409
|
|
|
410
|
+
def _create_kas_allowlist(self) -> KASAllowlist | None:
|
|
411
|
+
"""Create the KAS allowlist based on builder configuration.
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
KASAllowlist configured based on builder settings, or None if
|
|
415
|
+
allowlist validation is disabled.
|
|
416
|
+
|
|
417
|
+
"""
|
|
418
|
+
# If ignoring allowlist, return an allow-all instance
|
|
419
|
+
if self._ignore_kas_allowlist:
|
|
420
|
+
return KASAllowlist(allow_all=True)
|
|
421
|
+
|
|
422
|
+
# If explicit allowlist provided, use it
|
|
423
|
+
if self._kas_allowlist_urls:
|
|
424
|
+
allowlist = KASAllowlist(self._kas_allowlist_urls)
|
|
425
|
+
# Also add the platform URL for convenience
|
|
426
|
+
if self.platform_endpoint:
|
|
427
|
+
allowlist.add(self.platform_endpoint)
|
|
428
|
+
allowlist.add(self.platform_endpoint.rstrip("/") + "/kas")
|
|
429
|
+
return allowlist
|
|
430
|
+
|
|
431
|
+
# Default: create allowlist from platform URL only
|
|
432
|
+
if self.platform_endpoint:
|
|
433
|
+
return KASAllowlist.from_platform_url(self.platform_endpoint)
|
|
434
|
+
|
|
435
|
+
# No platform endpoint set yet - return None and let SDK handle it
|
|
436
|
+
return None
|
|
437
|
+
|
|
359
438
|
def _create_services(self) -> SDK.Services:
|
|
360
439
|
"""Create service client instances.
|
|
361
440
|
|
|
@@ -371,11 +450,15 @@ class SDKBuilder:
|
|
|
371
450
|
|
|
372
451
|
ssl_verify = not self.insecure_skip_verify
|
|
373
452
|
|
|
453
|
+
# Create the KAS allowlist
|
|
454
|
+
kas_allowlist = self._create_kas_allowlist()
|
|
455
|
+
|
|
374
456
|
class ServicesImpl(SDK.Services):
|
|
375
|
-
def __init__(self, builder_instance):
|
|
457
|
+
def __init__(self, builder_instance, allowlist: KASAllowlist | None):
|
|
376
458
|
self.closed = False
|
|
377
459
|
self._ssl_verify = ssl_verify
|
|
378
460
|
self._builder = builder_instance
|
|
461
|
+
self._kas_allowlist = allowlist
|
|
379
462
|
|
|
380
463
|
def kas(self) -> KAS:
|
|
381
464
|
"""Return the KAS interface with SSL verification settings."""
|
|
@@ -394,6 +477,7 @@ class SDKBuilder:
|
|
|
394
477
|
token_source=token_source,
|
|
395
478
|
sdk_ssl_verify=self._ssl_verify,
|
|
396
479
|
use_plaintext=self._builder.use_plaintext,
|
|
480
|
+
kas_allowlist=self._kas_allowlist,
|
|
397
481
|
)
|
|
398
482
|
return kas_impl
|
|
399
483
|
|
|
@@ -403,7 +487,7 @@ class SDKBuilder:
|
|
|
403
487
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
404
488
|
self.close()
|
|
405
489
|
|
|
406
|
-
return ServicesImpl(self)
|
|
490
|
+
return ServicesImpl(self, kas_allowlist)
|
|
407
491
|
|
|
408
492
|
def build(self) -> SDK:
|
|
409
493
|
"""Build and return an SDK instance with configured properties.
|
otdf_python/tdf.py
CHANGED
|
@@ -183,8 +183,8 @@ class TDF:
|
|
|
183
183
|
if isinstance(obj, PolicyBody):
|
|
184
184
|
# Convert data_attributes to dataAttributes and use null instead of empty array
|
|
185
185
|
result = {
|
|
186
|
-
"dataAttributes": obj.data_attributes
|
|
187
|
-
"dissem": obj.dissem
|
|
186
|
+
"dataAttributes": obj.data_attributes or None,
|
|
187
|
+
"dissem": obj.dissem or None,
|
|
188
188
|
}
|
|
189
189
|
return result
|
|
190
190
|
elif isinstance(obj, AttributeObject):
|
|
@@ -6,7 +6,7 @@ otdf_python/assertion_config.py,sha256=rw0SIB3xG-nAeb5r_liuxLphU4tcj-zlq8rVvXncX
|
|
|
6
6
|
otdf_python/asym_crypto.py,sha256=EYkMNhZJP5khH0IvICTOG2bMg_TMvd6wXDu5zW0jpj4,7234
|
|
7
7
|
otdf_python/auth_headers.py,sha256=uOLflFunBCw59nwk23rdiFQWOFrS19HugQXuQPGv3xE,986
|
|
8
8
|
otdf_python/autoconfigure_utils.py,sha256=W5aJ0tC7HWfGb_1Mva_oxgduUzSpPoyDeaq0rPwgPAs,3689
|
|
9
|
-
otdf_python/cli.py,sha256=
|
|
9
|
+
otdf_python/cli.py,sha256=icooiGgRh8H1IlP-_iO7paLB60IXUSaSacODABeqrS4,21165
|
|
10
10
|
otdf_python/collection_store.py,sha256=sYL6VMFDBfHfCCLk14iybeC_qoUlpJFB0wOMt1bdwpY,1429
|
|
11
11
|
otdf_python/collection_store_impl.py,sha256=3RqO3rvDCosajKpuls5DiO2_SWYsNQul9_9L7n-lQ68,758
|
|
12
12
|
otdf_python/config.py,sha256=l1Ykg1gFUrFZTnd6bwMI6oi_clR5uCZ_Y1qH7QKtW90,2523
|
|
@@ -20,14 +20,15 @@ otdf_python/ecdh.py,sha256=fwxE80qFSIkfJeUz3GNhEndRKkZrBN06FE1gnvwUHHI,10201
|
|
|
20
20
|
otdf_python/eckeypair.py,sha256=qcPKv0OS1lYxRICj9dhAW_eMz32anFBtpI8EJfXxpX0,2470
|
|
21
21
|
otdf_python/header.py,sha256=peG14kE_KAUCW4fY82sqcWF5zTAVAnJfFXCHtC8Z0iQ,7189
|
|
22
22
|
otdf_python/invalid_zip_exception.py,sha256=M_bAiXEjJdxPfA178YH-uHGRwMrNBKzjQzlQ54aDP2w,292
|
|
23
|
-
otdf_python/
|
|
23
|
+
otdf_python/kas_allowlist.py,sha256=0yX3J9J1od_ew0sp3pX6kfav39pTz7aBBpMnVkE5T7s,5884
|
|
24
|
+
otdf_python/kas_client.py,sha256=N5mQubRUrnJCtSxTiiyjf7DEfsg2wVZ02xufuZBH_b0,27532
|
|
24
25
|
otdf_python/kas_connect_rpc_client.py,sha256=cB3cBomCClmu-InUpw8R35cLyJQ8X0PoEsFvYSH0RUM,8950
|
|
25
26
|
otdf_python/kas_info.py,sha256=V-5om8k4RKbhE0h1CS1Rxb18TYcHKvq_hEPP6ah8K_o,738
|
|
26
27
|
otdf_python/kas_key_cache.py,sha256=6hfzRAg9o_IfRErWSe-_gGTG9kRyYENMizMY1Shkmfk,1548
|
|
27
28
|
otdf_python/key_type.py,sha256=2gQlXOj35J3ISCcWjU3sGYUxmlZR47BMq6Xr2yoKA8k,928
|
|
28
29
|
otdf_python/key_type_constants.py,sha256=MV2Dsea3A6nnnYztoD0N1QxhrbQXZfaXaqCr2rI6sqo,954
|
|
29
30
|
otdf_python/manifest.py,sha256=aglGw9EdtZZIxmwqy82sV5wum_mKkjzew4brSgxmJjc,7047
|
|
30
|
-
otdf_python/nanotdf.py,sha256=
|
|
31
|
+
otdf_python/nanotdf.py,sha256=y1mLktlZ-mRCup2vzGY4ZbFeNEhWpCYLOZYjzTSPEtc,33921
|
|
31
32
|
otdf_python/nanotdf_ecdsa_struct.py,sha256=jTQKFAicTfMfN9CxJZYQcnEYGmtfAQoDOhz8ta-pGAQ,4066
|
|
32
33
|
otdf_python/nanotdf_type.py,sha256=3MQzT6lJ3WJKMICFyyYZXX2_cFYcZ5G4m1uif-l9Nxo,1112
|
|
33
34
|
otdf_python/policy_binding_serializer.py,sha256=oOcGBYOISPTzHRtk8JszwLTraY_F2OoevOf0a53jGHA,1271
|
|
@@ -35,11 +36,11 @@ otdf_python/policy_info.py,sha256=aq74dZg9PhTZ6cMkZyFsu3D754C5YijFMiuoYEL-1bY,20
|
|
|
35
36
|
otdf_python/policy_object.py,sha256=LikIsahPkKr-iYA0lhgQitCbh8CsmxUBYyBs6VYfmxY,512
|
|
36
37
|
otdf_python/policy_stub.py,sha256=RfU_fICqsAOnTXOHpKhtKC0RJ3KoWhDxO0XecZWM548,159
|
|
37
38
|
otdf_python/resource_locator.py,sha256=bjK935XcfNq-PyqidHNq8eIiPeZEStYdQvmQ9B9GY20,6290
|
|
38
|
-
otdf_python/sdk.py,sha256
|
|
39
|
-
otdf_python/sdk_builder.py,sha256=
|
|
39
|
+
otdf_python/sdk.py,sha256=-3Zuyfvf8ZM6iqxFpFDRAxCSfe35r6-TIuOOGUbPNPM,15058
|
|
40
|
+
otdf_python/sdk_builder.py,sha256=xTPoBjB3eGwoWjfSgiPmiNnEZJ8g-LUQVZDmo7BM9cY,17649
|
|
40
41
|
otdf_python/sdk_exceptions.py,sha256=2GElGyM5LKN8Uh_lAiT6Ho4oNRWYRQsMNOK5s2jiv28,687
|
|
41
42
|
otdf_python/symmetric_and_payload_config.py,sha256=iPHeZSeY9BjsQ-wkvdm6-FIR7773EgGiaIvSG-ICOHw,1158
|
|
42
|
-
otdf_python/tdf.py,sha256=
|
|
43
|
+
otdf_python/tdf.py,sha256=lqaSSGfd-nzlmjpYoXa6ZLihZIJA170dwodKt_iRko8,20799
|
|
43
44
|
otdf_python/tdf_reader.py,sha256=WMetzX4CIKJ15f4J_zyFGtObQO6bQ33KC_ykIonH9ik,5228
|
|
44
45
|
otdf_python/tdf_writer.py,sha256=FLm1P26J4p6WPyKsjOb7QLYJqDIMDsBONqBW_JuFxyw,798
|
|
45
46
|
otdf_python/token_source.py,sha256=YHbP7deSSXo1CvzVGJX7DkOuBgqwfP_Ockm8CE-MN0o,1011
|
|
@@ -140,7 +141,7 @@ otdf_python_proto/wellknownconfiguration/__init__.py,sha256=xK8XUrwCL9elWuMTx6vV
|
|
|
140
141
|
otdf_python_proto/wellknownconfiguration/wellknown_configuration_connect.py,sha256=fVQfo4OsTK8e63ytmiGyMe1AxgirktNqI_O3rQ6o6Nc,6342
|
|
141
142
|
otdf_python_proto/wellknownconfiguration/wellknown_configuration_pb2.py,sha256=g9xSm9TxX0IPMqiFCaridJvI2TrL8PrXVFPgu8tX9VM,3863
|
|
142
143
|
otdf_python_proto/wellknownconfiguration/wellknown_configuration_pb2.pyi,sha256=Zw4vROvTgomnFqsalJrYda632ojXH0FVXSzTXxerybw,1490
|
|
143
|
-
otdf_python-0.4.
|
|
144
|
-
otdf_python-0.4.
|
|
145
|
-
otdf_python-0.4.
|
|
146
|
-
otdf_python-0.4.
|
|
144
|
+
otdf_python-0.4.3.dist-info/METADATA,sha256=OCF50LC7pWodNWzwurJp5yWLBleo01O7g3p6QhRo5Ug,5175
|
|
145
|
+
otdf_python-0.4.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
146
|
+
otdf_python-0.4.3.dist-info/licenses/LICENSE,sha256=DPrPHdI6tfZcqk9kzQ37vh1Ftk7LJYdMrUtwKl7L3Pw,1074
|
|
147
|
+
otdf_python-0.4.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|