Skip to content

Commit 2dbda63

Browse files
fix(security): validate workspace_id and region parameters to prevent hostname injection attacks
1 parent 026760b commit 2dbda63

6 files changed

Lines changed: 298 additions & 2 deletions

File tree

‎dashscope/__init__.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
base_compatible_api_url,
4141
base_http_api_url,
4242
base_websocket_api_url,
43+
validate_workspace_id,
4344
)
4445
from dashscope.finetune.deployments import Deployments
4546
from dashscope.finetune.finetunes import FineTunes
@@ -103,8 +104,8 @@ def set_region(region: str, workspace_id: str = None):
103104
of the MaaS endpoint.
104105
105106
Raises:
106-
ValueError: If region is not supported or workspace_id is
107-
empty.
107+
ValueError: If region is not supported, workspace_id is
108+
empty, or workspace_id is not a valid hostname label.
108109
"""
109110
if region not in _MAAS_REGIONS:
110111
raise ValueError(
@@ -115,6 +116,8 @@ def set_region(region: str, workspace_id: str = None):
115116
if not workspace_id:
116117
raise ValueError("workspace_id is required")
117118

119+
validate_workspace_id(workspace_id)
120+
118121
global base_http_api_url, base_compatible_api_url
119122
global base_websocket_api_url
120123

‎dashscope/agentstudio/client.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
)
3838
from dashscope.agentstudio.transport import SyncTransport, AsyncTransport
3939
from dashscope.common.api_key import get_default_api_key
40+
from dashscope.common.env import validate_region, validate_workspace_id
4041

4142

4243
def _resolve_base_url(
@@ -50,6 +51,10 @@ def _resolve_base_url(
5051
1. explicit base_url parameter (full URL)
5152
2. DASHSCOPE_AGENTSTUDIO_URL / AGENTSTUDIO_URL env
5253
3. Build from workspace + region template
54+
55+
Raises:
56+
ValueError: If workspace or region is missing, or is not a valid
57+
hostname label.
5358
"""
5459
if explicit_url:
5560
return explicit_url
@@ -67,6 +72,11 @@ def _resolve_base_url(
6772
"or use base_url=... to override)",
6873
)
6974
rgn = region or AGENTSTUDIO_DEFAULT_REGION
75+
# Both are interpolated into the host below; unvalidated values such as
76+
# "a@evil.com/#" would move the effective host off *.maas.aliyuncs.com
77+
# while the transport still sends Authorization: Bearer <api_key>.
78+
validate_workspace_id(ws)
79+
validate_region(rgn)
7080
return AGENTSTUDIO_BASE_URL_TEMPLATE.format(
7181
workspace=ws,
7282
region=rgn,

‎dashscope/common/env.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# Copyright (c) Alibaba, Inc. and its affiliates.
33

44
import os
5+
import re
56

67
from dashscope.common.constants import (
78
DASHSCOPE_API_KEY_ENV,
@@ -11,6 +12,55 @@
1112
)
1213
from dashscope.common.error import InputRequired
1314

15+
# workspace_id and region become DNS labels of the MaaS endpoint host, so
16+
# they must not contain characters (/, ?, #, @, :, whitespace, ...) that
17+
# could break out of the host and redirect requests elsewhere.
18+
# \A...\Z rather than ^...$: $ also matches before a trailing newline.
19+
_WORKSPACE_ID_PATTERN = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9_-]{0,63}\Z")
20+
# RFC 1123 hostname label: 1-63 chars, no leading/trailing hyphen. Upper
21+
# case is allowed because DNS is case-insensitive and httpx lowercases the
22+
# host, so rejecting it would break working callers for no security gain.
23+
_REGION_PATTERN = re.compile(
24+
r"\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\Z",
25+
)
26+
27+
28+
def validate_workspace_id(workspace_id):
29+
"""Validate that workspace_id is safe to use as a URL subdomain.
30+
31+
Raises:
32+
ValueError: If workspace_id contains characters outside
33+
[A-Za-z0-9_-], is empty, or exceeds 64 characters.
34+
"""
35+
if not workspace_id or not _WORKSPACE_ID_PATTERN.fullmatch(
36+
workspace_id,
37+
):
38+
raise ValueError(
39+
f"Invalid workspace_id {workspace_id!r}: must match "
40+
"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$",
41+
)
42+
return workspace_id
43+
44+
45+
def validate_region(region):
46+
"""Validate that region is safe to use as a URL subdomain.
47+
48+
Deliberately a hostname-label check and not a MAAS_REGIONS whitelist:
49+
rejecting an unknown-but-well-formed region would break callers as soon
50+
as a subpackage supports a region this list has not caught up with.
51+
52+
Raises:
53+
ValueError: If region is not a valid hostname label.
54+
"""
55+
if not region or not _REGION_PATTERN.fullmatch(region):
56+
raise ValueError(
57+
f"Invalid region {region!r}: must be a hostname label "
58+
"(1-63 chars of [A-Za-z0-9-], not starting or ending "
59+
"with '-')",
60+
)
61+
return region
62+
63+
1464
# MaaS regions: region -> URL subdomain identifier
1565
# cn-beijing is deliberately excluded: it is the default
1666
# DASHSCOPE_API_REGION and must keep using the legacy
@@ -36,6 +86,8 @@
3686
# define api base url, ensure end /
3787
if api_region in MAAS_REGIONS:
3888
_maas_region_id = MAAS_REGIONS[api_region]
89+
if _default_workspace_id:
90+
validate_workspace_id(_default_workspace_id)
3991
_ws = _default_workspace_id if _default_workspace_id else "{workspace_id}"
4092
_maas_host = f"{_ws}.{_maas_region_id}.maas.aliyuncs.com"
4193
base_http_api_url = os.environ.get(
@@ -83,6 +135,7 @@ def resolve_base_url(url, workspace_id=None):
83135
return url
84136
ws = workspace_id or _default_workspace_id
85137
if ws:
138+
validate_workspace_id(ws)
86139
return url.replace("{workspace_id}", ws)
87140
raise InputRequired(
88141
"The base URL contains '{workspace_id}' but no workspace id "
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# -*- coding: utf-8 -*-
2+
"""AgentStudio base URL resolution.
3+
4+
``workspace`` and ``region`` are interpolated into the MaaS host
5+
template ``https://{workspace}.{region}.maas.aliyuncs.com``. Both reach
6+
:func:`_resolve_base_url` from a public ``Client`` keyword argument or
7+
from the ``DASHSCOPE_WORKSPACE`` env var, so both are validated as DNS
8+
labels: an unvalidated ``"a@evil.com/#"`` makes httpx resolve the host
9+
to ``evil.com`` while the transport still attaches
10+
``Authorization: Bearer <api_key>``.
11+
"""
12+
13+
import httpx
14+
import pytest
15+
16+
from dashscope.agentstudio.client import _resolve_base_url
17+
18+
MAAS_SUFFIX = ".maas.aliyuncs.com"
19+
20+
# Values that break out of the host component when interpolated raw.
21+
HOST_BREAKING = [
22+
"a@evil.com/#",
23+
"evil.com/x",
24+
"evil.com:443",
25+
"evil.com?x",
26+
"evil.com#frag",
27+
"white space",
28+
"abc\n",
29+
]
30+
31+
32+
@pytest.fixture(autouse=True)
33+
def _clean_env(monkeypatch):
34+
"""Keep ambient env vars from changing resolution precedence."""
35+
for var in [
36+
"DASHSCOPE_WORKSPACE",
37+
"DASHSCOPE_AGENTSTUDIO_URL",
38+
"AGENTSTUDIO_URL",
39+
]:
40+
monkeypatch.delenv(var, raising=False)
41+
42+
43+
def test_builds_url_from_workspace_and_region():
44+
url = _resolve_base_url(None, "ws-demo", "cn-beijing")
45+
assert url == (
46+
"https://ws-demo.cn-beijing.maas.aliyuncs.com/api/v1/agentstudio"
47+
)
48+
49+
50+
def test_region_defaults_when_omitted():
51+
url = _resolve_base_url(None, "ws-demo", None)
52+
assert httpx.URL(url).host == f"ws-demo.cn-beijing{MAAS_SUFFIX}"
53+
54+
55+
@pytest.mark.parametrize("bad", HOST_BREAKING)
56+
def test_rejects_host_breaking_workspace(bad):
57+
with pytest.raises(ValueError, match="workspace_id"):
58+
_resolve_base_url(None, bad, "cn-beijing")
59+
60+
61+
@pytest.mark.parametrize("bad", HOST_BREAKING + ["-dash", "a" * 64])
62+
def test_rejects_host_breaking_region(bad):
63+
with pytest.raises(ValueError, match="region"):
64+
_resolve_base_url(None, "ws-demo", bad)
65+
66+
67+
def test_uppercase_region_still_resolves():
68+
"""Compat guard: httpx lowercases the host, so case must not be rejected."""
69+
url = _resolve_base_url(None, "ws-demo", "CN-Beijing")
70+
assert httpx.URL(url).host == f"ws-demo.cn-beijing{MAAS_SUFFIX}"
71+
72+
73+
def test_rejects_workspace_from_env(monkeypatch):
74+
"""The env-sourced workspace is validated on the same path."""
75+
monkeypatch.setenv("DASHSCOPE_WORKSPACE", "a@evil.com/#")
76+
with pytest.raises(ValueError, match="workspace_id"):
77+
_resolve_base_url(None, None, "cn-beijing")
78+
79+
80+
def test_resolved_host_stays_under_maas_domain():
81+
"""Positive control for the property the checks above protect."""
82+
url = _resolve_base_url(None, "llm-abc_def", "ap-southeast-1")
83+
assert httpx.URL(url).host.endswith(MAAS_SUFFIX)
84+
85+
86+
def test_missing_workspace_error_is_actionable():
87+
with pytest.raises(ValueError, match="workspace is required"):
88+
_resolve_base_url(None, None, "cn-beijing")
89+
90+
91+
def test_explicit_base_url_overrides_without_validation():
92+
"""A deliberate full-URL override is trusted, per documented priority."""
93+
assert _resolve_base_url("https://my.host/api", None, None) == (
94+
"https://my.host/api"
95+
)
96+
97+
98+
def test_env_base_url_overrides_without_validation(monkeypatch):
99+
monkeypatch.setenv("DASHSCOPE_AGENTSTUDIO_URL", "https://my.host/api")
100+
assert _resolve_base_url(None, None, None) == "https://my.host/api"
101+
102+
103+
def test_sync_client_rejects_injected_workspace():
104+
"""The attack surface is the public constructor, not just the helper."""
105+
from dashscope.agentstudio import Client
106+
107+
with pytest.raises(ValueError, match="workspace_id"):
108+
Client(api_key="test-key", workspace="a@evil.com/#")
109+
110+
111+
def test_async_client_rejects_injected_region():
112+
from dashscope.agentstudio import AsyncClient
113+
114+
with pytest.raises(ValueError, match="region"):
115+
AsyncClient(
116+
api_key="test-key",
117+
workspace="ws-demo",
118+
region="x@evil.com/#",
119+
)

‎tests/unit/test_maas_region.py‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,84 @@ def test_resolve_all_regions(self):
5151
f"https://my-ws.{region_id}.maas.aliyuncs.com/api/v1",
5252
)
5353

54+
def test_resolve_rejects_invalid_workspace(self):
55+
url = "https://{workspace_id}.us-east-1.maas.aliyuncs.com/api/v1"
56+
for bad in ["evil.com/x", "a@evil.com", "evil.com#f", "evil com"]:
57+
with self.assertRaises(ValueError):
58+
resolve_base_url(url, bad)
59+
60+
def test_resolve_rejects_invalid_env_default_workspace(self):
61+
import dashscope.common.env as env_mod
62+
63+
url = "https://{workspace_id}.us-east-1.maas.aliyuncs.com/api/v1"
64+
with patch.object(env_mod, "_default_workspace_id", "evil.com/x"):
65+
with self.assertRaises(ValueError):
66+
resolve_base_url(url, None)
67+
68+
69+
class TestValidateWorkspaceId(unittest.TestCase):
70+
"""Test validate_workspace_id character restrictions."""
71+
72+
def test_valid_ids(self):
73+
from dashscope.common.env import validate_workspace_id
74+
75+
for valid in ["ws-123", "llm-abc_def", "A0", "a" * 64]:
76+
self.assertEqual(validate_workspace_id(valid), valid)
77+
78+
def test_invalid_ids(self):
79+
from dashscope.common.env import validate_workspace_id
80+
81+
for invalid in [
82+
"",
83+
None,
84+
"-leading-dash",
85+
"evil.com",
86+
"evil.com/x",
87+
"evil.com?x",
88+
"evil.com#f",
89+
"a@evil.com",
90+
"evil.com:443",
91+
"white space",
92+
"a" * 65,
93+
# $ also matches before a trailing newline; \Z does not
94+
"abc\n",
95+
]:
96+
with self.assertRaises(ValueError):
97+
validate_workspace_id(invalid)
98+
99+
100+
class TestValidateRegion(unittest.TestCase):
101+
"""Test validate_region character restrictions."""
102+
103+
def test_valid_regions(self):
104+
from dashscope.common.env import validate_region
105+
106+
# Upper case is accepted: DNS is case-insensitive and httpx
107+
# lowercases the host, so rejecting it broke working callers.
108+
for valid in [*MAAS_REGIONS, "cn-beijing", "CN-Beijing", "a"]:
109+
self.assertEqual(validate_region(valid), valid)
110+
111+
def test_invalid_regions(self):
112+
from dashscope.common.env import validate_region
113+
114+
for invalid in [
115+
"",
116+
None,
117+
# would move the effective host off *.maas.aliyuncs.com
118+
"x@evil.com/#",
119+
"evil.com/x",
120+
"evil.com:443",
121+
"evil.com?x",
122+
"white space",
123+
"-leading-dash",
124+
"trailing-dash-",
125+
"abc\n",
126+
# RFC 1123 caps a label at 63 chars
127+
"a" * 64,
128+
]:
129+
with self.assertRaises(ValueError):
130+
validate_region(invalid)
131+
54132

55133
class TestMaasRegions(unittest.TestCase):
56134
"""Test MAAS_REGIONS constant."""
@@ -128,6 +206,16 @@ def test_maas_region_with_workspace(self):
128206
"https://ws-test-123.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
129207
)
130208

209+
def test_maas_region_with_invalid_workspace_raises(self):
210+
"""Invalid DASHSCOPE_WORKSPACE_ID must fail fast at env load."""
211+
with self.assertRaises(ValueError):
212+
self._reload_env(
213+
{
214+
"DASHSCOPE_API_REGION": "ap-southeast-1",
215+
"DASHSCOPE_WORKSPACE_ID": "evil.com/x",
216+
},
217+
)
218+
131219
def test_maas_region_without_workspace(self):
132220
env = self._reload_env(
133221
{"DASHSCOPE_API_REGION": "us-east-1"},

‎tests/unit/test_set_region.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,29 @@ def test_set_region_none_workspace_id(self):
139139

140140
assert "workspace_id is required" in str(exc_info.value)
141141

142+
@pytest.mark.parametrize(
143+
"bad_workspace_id",
144+
[
145+
"evil.com/x",
146+
"evil.com?x=1",
147+
"evil.com#frag",
148+
"a@evil.com",
149+
"evil.com:443",
150+
"evil com",
151+
"evil.com",
152+
"-evil",
153+
"a" * 65,
154+
],
155+
)
156+
def test_set_region_invalid_workspace_id(self, bad_workspace_id):
157+
"""workspace_id with URL-breaking characters must be rejected."""
158+
with pytest.raises(ValueError) as exc_info:
159+
dashscope.set_region("ap-southeast-1", bad_workspace_id)
160+
161+
assert "Invalid workspace_id" in str(exc_info.value)
162+
# Globals must not be modified on rejection.
163+
assert bad_workspace_id not in dashscope.base_http_api_url
164+
142165
def test_set_region_multiple_times(self):
143166
"""Test switching between multiple regions."""
144167
# First region

0 commit comments

Comments
 (0)