Skip to content

Commit 030a5d5

Browse files
authored
Block client props in Dashboard creation and PATCH requests (#5290)
### Description Dashboard client properties can supply provisioning paths that cause server-local files to be included in downloadable kits. Reject client creation and PATCH requests containing `props` with HTTP 400 before changing any stored data, for both client creators and project admins, regardless of approval state or the supplied value. Client-kit generation also ignores previously stored `Client.props`, including malformed JSON, so legacy records cannot continue injecting provisioning properties after the API restriction takes effect. Client capacity and operator-managed properties from `properties.yml` or `properties.json` remain supported. Existing rows do not require migration. API integrations that submit `props` must omit it. The current Dashboard UI's client name and capacity requests continue to work. ### Types of changes - [x] Breaking change: client API requests containing `props` are now rejected. - [x] New tests cover creation and PATCH, creator and admin access, pending and approved clients, empty/null/invalid values, and rejection without partial updates. - [x] Kit-generation regression tests seed legacy properties directly in the database and download the actual ZIP as the client creator. They verify that the target file is excluded, stored connection security is ignored, and client capacity and operator-managed transport properties still reach the kit. ### Validation - `./.venv/bin/python -m pytest tests/unit_test/dashboard -q --disable-warnings`: 73 passed; existing SQLAlchemy and JWT warnings remain. - Before the provisioning-boundary fix, the new legacy-path regression failed because the downloaded kit contained `customRootCA.pem`; both legacy-path and malformed-JSON cases pass after the fix. - Scoped equivalent of the project style check passed: Black on each changed file, isort, flake8, agent skill checks, and `git diff --check`. Black's combined file check stalled, so formatting was checked per file.
1 parent 53ba7ee commit 030a5d5

3 files changed

Lines changed: 155 additions & 4 deletions

File tree

nvflare/dashboard/application/blob.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,9 @@ def gen_client_blob(key, id):
154154
def _prepare_client(prop_mgr: PropertyManager, prov_project: ProvProject, client_id):
155155
client = Client.query.get(client_id)
156156
inc_dl(Client, client_id)
157-
if client.props:
158-
props = json.loads(client.props)
159-
else:
160-
props = {}
157+
# Legacy client.props may contain user-supplied provisioning paths. Never use
158+
# them in kits; additional properties must come from operator configuration.
159+
props = {}
161160

162161
if client.capacity:
163162
props[PropKey.CAPACITY] = json.loads(client.capacity)

nvflare/dashboard/application/clients.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
def create_one_client():
2828
creator = get_jwt_identity()
2929
req = request.json
30+
if "props" in req:
31+
return jsonify({"status": "error", "message": "Client props cannot be supplied through the Dashboard API"}), 400
3032
result = Store.create_client(req, creator)
3133
if result is not None:
3234
return jsonify(result), 201
@@ -66,6 +68,11 @@ def update_client(id):
6668

6769
if request.method == "PATCH":
6870
req = request.json
71+
if "props" in req:
72+
return (
73+
jsonify({"status": "error", "message": "Client props cannot be supplied through the Dashboard API"}),
74+
400,
75+
)
6976
if p:
7077
result = Store.patch_client_by_project_admin(id, req)
7178
elif c:

tests/unit_test/dashboard/clients_test.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,17 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14+
import io
15+
import json
16+
from uuid import uuid4
17+
from zipfile import ZipFile
18+
1419
import pytest
1520

21+
from nvflare.dashboard.application import db
1622
from nvflare.dashboard.application.constants import FLARE_DASHBOARD_NAMESPACE
23+
from nvflare.dashboard.application.models import Client, Project
24+
from nvflare.dashboard.application.store import Store
1725

1826
CLIENT1 = {"name": "site-1", "organization": "test.com", "capacity": {"num_gpus": 16, "mem_per_gpu_in_GiB": 64}}
1927

@@ -69,3 +77,140 @@ def test_update_client(self, client, client_ids, auth_header):
6977

7078
assert response.status_code == 200
7179
assert response.json["client"]["organization"] == NEW_ORG
80+
81+
82+
class TestClientPropsRejection:
83+
@pytest.fixture(scope="class")
84+
@classmethod
85+
def creator_header(cls, client, auth_header):
86+
email = f"client-creator-{uuid4().hex}@test.com"
87+
response = client.post(
88+
FLARE_DASHBOARD_NAMESPACE + "/api/v1/users",
89+
json={"email": email, "password": "test-password", "organization": "test.com", "role": "member"},
90+
)
91+
assert response.status_code == 201
92+
user_id = response.json["user"]["id"]
93+
response = client.post(
94+
FLARE_DASHBOARD_NAMESPACE + "/api/v1/login", json={"email": email, "password": "test-password"}
95+
)
96+
assert response.status_code == 200
97+
yield {"Authorization": "Bearer " + response.json["access_token"]}
98+
response = client.delete(FLARE_DASHBOARD_NAMESPACE + f"/api/v1/users/{user_id}", headers=auth_header)
99+
assert response.status_code == 200
100+
101+
@pytest.mark.parametrize("as_admin", [False, True])
102+
@pytest.mark.parametrize("props", [{"custom_ca_cert": "/server/private-file"}, {}, None, "invalid", []])
103+
def test_create_rejects_props(self, client, auth_header, creator_header, as_admin, props):
104+
url = FLARE_DASHBOARD_NAMESPACE + "/api/v1/clients"
105+
before = client.get(url, headers=auth_header).json["client_list"]
106+
response = client.post(
107+
url,
108+
json={**CLIENT1, "name": f"props-{uuid4().hex}", "props": props},
109+
headers=auth_header if as_admin else creator_header,
110+
)
111+
try:
112+
assert response.status_code == 400
113+
assert response.json["status"] == "error"
114+
assert "props" in response.json["message"]
115+
assert client.get(url, headers=auth_header).json["client_list"] == before
116+
finally:
117+
if response.status_code == 201:
118+
client.delete(url + f"/{response.json['client']['id']}", headers=auth_header)
119+
120+
@pytest.mark.parametrize("as_admin", [False, True])
121+
@pytest.mark.parametrize("approval_state", [0, 100, 200])
122+
@pytest.mark.parametrize("props", [{"custom_ca_cert": "/server/private-file"}, {}, None, "invalid", []])
123+
def test_patch_rejects_props(self, client, auth_header, creator_header, as_admin, approval_state, props):
124+
response = client.post(
125+
FLARE_DASHBOARD_NAMESPACE + "/api/v1/clients",
126+
json={**CLIENT1, "name": f"props-{uuid4().hex}"},
127+
headers=creator_header,
128+
)
129+
assert response.status_code == 201
130+
url = FLARE_DASHBOARD_NAMESPACE + f"/api/v1/clients/{response.json['client']['id']}"
131+
try:
132+
response = client.patch(url, json={"approval_state": approval_state}, headers=auth_header)
133+
assert response.status_code == 200
134+
before = client.get(url, headers=auth_header).json["client"]
135+
response = client.patch(
136+
url,
137+
json={"props": props, "name": "changed-name", "approval_state": -1},
138+
headers=auth_header if as_admin else creator_header,
139+
)
140+
assert response.status_code == 400
141+
assert response.json["status"] == "error"
142+
assert "props" in response.json["message"]
143+
assert client.get(url, headers=auth_header).json["client"] == before
144+
145+
# Ordinary client fields remain editable when props is absent.
146+
capacity = {"num_of_gpus": 2, "mem_per_gpu_in_GiB": 16}
147+
response = client.patch(url, json={"capacity": capacity}, headers=creator_header)
148+
assert response.status_code == 200
149+
assert response.json["client"]["capacity"] == capacity
150+
finally:
151+
response = client.delete(url, headers=auth_header)
152+
assert response.status_code == 200
153+
154+
@pytest.mark.parametrize("malformed_props", [False, True])
155+
def test_blob_ignores_legacy_props(
156+
self, app, client, auth_header, creator_header, tmp_path, monkeypatch, malformed_props
157+
):
158+
secret = b"legacy-client-props-must-not-disclose-this-file"
159+
target = tmp_path / "private-file"
160+
target.write_bytes(secret)
161+
legacy_props = (
162+
"invalid JSON"
163+
if malformed_props
164+
else json.dumps({"custom_ca_cert": str(target), "connection_security": "clear"})
165+
)
166+
# Operator-managed properties and the client's capacity must still reach the kit.
167+
monkeypatch.setenv("NVFL_WEB_ROOT", str(tmp_path))
168+
(tmp_path / "properties.json").write_text(json.dumps({"client": {"use_aio": True}}))
169+
capacity = {"num_of_gpus": 2, "mem_per_gpu_in_GiB": 16}
170+
response = client.post(
171+
FLARE_DASHBOARD_NAMESPACE + "/api/v1/clients",
172+
json={"name": f"legacy-{uuid4().hex}", "organization": "testorg", "capacity": capacity},
173+
headers=creator_header,
174+
)
175+
assert response.status_code == 201
176+
client_id = response.json["client"]["id"]
177+
url = FLARE_DASHBOARD_NAMESPACE + f"/api/v1/clients/{client_id}"
178+
with app.app_context():
179+
project = Project.query.first()
180+
original = {key: getattr(project, key) for key in ("short_name", "server1", "root_key", "root_cert")}
181+
project.short_name = "legacy-test"
182+
project.server1 = "server.test.com"
183+
Store.build_project(project)
184+
legacy_client = db.session.get(Client, client_id)
185+
legacy_client.props = legacy_props
186+
legacy_client.approval_state = 200
187+
db.session.commit()
188+
189+
try:
190+
response = client.post(url + "/blob", json={"pin": "1234"}, headers=creator_header)
191+
assert response.status_code == 200
192+
assert response.headers["Content-Type"] == "zip"
193+
with ZipFile(io.BytesIO(response.data)) as kit:
194+
assert not any(name.endswith("customRootCA.pem") for name in kit.namelist())
195+
for name in kit.namelist():
196+
if not name.endswith("/"):
197+
assert secret not in kit.read(name, pwd=b"1234")
198+
resources = json.loads(kit.read("local/resources.json.default", pwd=b"1234"))
199+
resource_manager = next(c for c in resources["components"] if c["id"] == "resource_manager")
200+
for key, value in capacity.items():
201+
assert resource_manager["args"][key] == value
202+
config = json.loads(kit.read("startup/fed_client.json", pwd=b"1234"))
203+
assert config["client"]["connection_security"] == "mtls"
204+
assert config["servers"][0]["service"]["scheme"] == "agrpc"
205+
206+
with app.app_context():
207+
# Safety comes from ignoring stored props, without depending on a migration.
208+
assert db.session.get(Client, client_id).props == legacy_props
209+
finally:
210+
response = client.delete(url, headers=auth_header)
211+
assert response.status_code == 200
212+
with app.app_context():
213+
project = Project.query.first()
214+
for key, value in original.items():
215+
setattr(project, key, value)
216+
db.session.commit()

0 commit comments

Comments
 (0)