Conversation
This reverts commit 7ce45fa.
…g unit tests, use NVFlare controller classes instead
…d by the default server controller
chore: Update APT versions in Dockerfile
Extend integration tests
chore: Update APT versions in Dockerfile
…d env_config for team1
| app = FastAPI() | ||
|
|
||
| def read_text(p: Path, limit: int = 50000) -> str: | ||
| if not p.exists(): |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 5 days ago
In general, to fix uncontrolled path usage you must (1) construct paths relative to a trusted base directory, (2) normalize the resulting path, and (3) verify that the normalized path still lies within the base directory before accessing the filesystem. If the check fails, return an error (or empty content) instead of reading the file.
For this code, the best non-breaking fix is to introduce a small helper that safely joins BASE with the user-provided components site, mode, and run_id, normalizes the result, and ensures that the final path is inside BASE. Then use this helper in heartbeat, console, and log to derive the directory path; from there, append the fixed filenames (heartbeat.json, nohup.out, etc.) using simple / concatenation on Path, which is safe once the directory has been validated. This preserves existing behavior for valid inputs while blocking traversal attempts.
Concretely, within server_tools/app.py:
- Add a helper
safe_run_path(site: str, mode: str, run_id: str) -> Path | None(or justPathwith error handling) above the route handlers. This function should:- Build a candidate path:
candidate = (BASE / site / mode / run_id).resolve(). - Ensure
candidateis withinBASE, for example by comparingcandidate’s parts toBASE’s parts or checking thatstr(candidate).startswith(str(BASE) + os.sep)after normalization; withPath, a robust, cross-platform check isBASE in candidate.parents or candidate == BASEis not quite right, so instead ensureBASE == candidateorBASE == next(iter(candidate.parents))—but the simplest is to checkBASE == candidate or BASE in candidate.parents. Here, sincecandidatemust be deeper thanBASE, we verifyBASE == candidateorBASE in candidate.parents. - If the check fails, return
None.
- Build a candidate path:
- Update
heartbeatto:- Call
safe_run_path(site, mode, run_id)to getrun_dir. - If
run_dirisNone, return an empty string. - Build
p = run_dir / nameinstead of usingBASE / site / mode / run_id / name.
- Call
- Update
consolesimilarly: computerun_dirwith the helper, thenp1 = run_dir / "nohup.out"andp2 = run_dir / "local_training_console_output.txt", guarding the case whererun_dirisNone. - Update
logto use the helper and return an empty string if validation fails.
We already import Path and do not need additional third-party libraries; we only add one helper function, plus possibly Optional typing if desired (but that’s not strictly necessary). All changes stay within server_tools/app.py.
| @@ -12,6 +12,23 @@ | ||
| return "" | ||
| return p.read_text(errors="replace")[-limit:] | ||
|
|
||
| def safe_run_path(site: str, mode: str, run_id: str) -> Path | None: | ||
| """ | ||
| Construct a path under BASE from user-provided components and ensure that | ||
| the resulting path is contained within BASE. Returns None if the path | ||
| would escape BASE. | ||
| """ | ||
| candidate = (BASE / site / mode / run_id).resolve() | ||
| try: | ||
| # Ensure the resolved path is within BASE to prevent path traversal. | ||
| if candidate == BASE or BASE in candidate.parents: | ||
| return candidate | ||
| except Exception: | ||
| # In case of any unexpected error during resolution or checking, | ||
| # treat the path as unsafe. | ||
| pass | ||
| return None | ||
|
|
||
| def parse_age(ts: str) -> str: | ||
| if not ts: | ||
| return "unknown" | ||
| @@ -90,16 +107,22 @@ | ||
|
|
||
| @app.get("/heartbeat/{site}/{mode}/{run_id}", response_class=PlainTextResponse) | ||
| def heartbeat(site: str, mode: str, run_id: str): | ||
| run_dir = safe_run_path(site, mode, run_id) | ||
| if run_dir is None: | ||
| return "" | ||
| for name in ["heartbeat.json", "heartbeat_final.json"]: | ||
| p = BASE / site / mode / run_id / name | ||
| p = run_dir / name | ||
| if p.exists(): | ||
| return read_text(p) | ||
| return "" | ||
|
|
||
| @app.get("/console/{site}/{mode}/{run_id}", response_class=PlainTextResponse) | ||
| def console(site: str, mode: str, run_id: str): | ||
| p1 = BASE / site / mode / run_id / "nohup.out" | ||
| p2 = BASE / site / mode / run_id / "local_training_console_output.txt" | ||
| run_dir = safe_run_path(site, mode, run_id) | ||
| if run_dir is None: | ||
| return "" | ||
| p1 = run_dir / "nohup.out" | ||
| p2 = run_dir / "local_training_console_output.txt" | ||
| if p1.exists(): | ||
| return read_text(p1) | ||
| if p2.exists(): | ||
| @@ -108,4 +124,7 @@ | ||
|
|
||
| @app.get("/log/{site}/{mode}/{run_id}", response_class=PlainTextResponse) | ||
| def log(site: str, mode: str, run_id: str): | ||
| return read_text(BASE / site / mode / run_id / "log.txt") | ||
| run_dir = safe_run_path(site, mode, run_id) | ||
| if run_dir is None: | ||
| return "" | ||
| return read_text(run_dir / "log.txt") |
| def read_text(p: Path, limit: int = 50000) -> str: | ||
| if not p.exists(): | ||
| return "" | ||
| return p.read_text(errors="replace")[-limit:] |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 5 days ago
In general, to fix this kind of issue you must ensure that any path built from user input is confined to an intended root directory. That is usually done by: (1) constructing the candidate path under a known base directory, (2) normalizing / resolving it to remove .. and similar, and (3) verifying that the final path is still inside the base directory. If the check fails, return an error or empty content instead of touching the filesystem.
For this codebase, the least intrusive and most consistent fix is to add a small helper that safely joins BASE with site, mode, and run_id plus the target filename, using Path.resolve() to normalize and then checking that the resolved path is within BASE. We then use this helper in heartbeat, console, and log instead of directly doing BASE / site / mode / run_id / .... If the resulting path is outside BASE, we treat it as non‑existent and return an empty string, preserving the current observable behavior (these handlers already return "" on missing files). We can also perform a quick early rejection of path components containing directory separators to further reduce risk, but the critical protection is the resolve-and-check step.
Concretely, in server_tools/app.py:
- Add a function, e.g.
safe_path(*parts: str) -> Path | None, just afterBASEandapp(or afterread_text), that:- Builds
candidate = BASE.joinpath(*parts). - Resolves it:
resolved = candidate.resolve(). - Checks
resolved.is_relative_to(BASE)if available (Python 3.9+), otherwise falls back to checkingBASE in resolved.parents or resolved == BASE. - Returns
resolvedif it is insideBASE, otherwise returnsNone.
- Builds
- Optionally, inside
safe_path, reject any part that contains/or\to force components to be single path segments. - Update
heartbeat,console, andlogto:- Call
safe_path(site, mode, run_id, filename)to obtain each path. - If
safe_pathreturnsNoneor the file doesn’t exist, skip/return""as they currently do.
- Call
- Leave
read_textunchanged; it will now only ever be called with paths that have passed the safety check.
This centralizes the validation in one helper, fixes all 9 variants at once, and does not change successful responses for valid paths under BASE.
| @@ -7,6 +7,33 @@ | ||
| BASE = Path("/srv/mediswarm/live") | ||
| app = FastAPI() | ||
|
|
||
| def safe_path(*parts: str) -> Path | None: | ||
| """ | ||
| Construct a path under BASE from the given string components, ensuring | ||
| that the resulting resolved path stays within BASE. Returns None if | ||
| the path would escape BASE. | ||
| """ | ||
| # Reject components that try to smuggle in path separators | ||
| for part in parts: | ||
| if "/" in part or "\\" in part: | ||
| return None | ||
| candidate = BASE.joinpath(*parts) | ||
| try: | ||
| resolved = candidate.resolve() | ||
| except FileNotFoundError: | ||
| # Even if intermediate components do not exist, resolve as far as possible | ||
| resolved = candidate.resolve(strict=False) | ||
| try: | ||
| # Python 3.9+: use is_relative_to if available | ||
| is_inside = resolved.is_relative_to(BASE) # type: ignore[attr-defined] | ||
| except AttributeError: | ||
| # Fallback for older Python versions | ||
| resolved_base = BASE.resolve() | ||
| is_inside = resolved == resolved_base or resolved_base in resolved.parents | ||
| if not is_inside: | ||
| return None | ||
| return resolved | ||
|
|
||
| def read_text(p: Path, limit: int = 50000) -> str: | ||
| if not p.exists(): | ||
| return "" | ||
| @@ -91,21 +118,24 @@ | ||
| @app.get("/heartbeat/{site}/{mode}/{run_id}", response_class=PlainTextResponse) | ||
| def heartbeat(site: str, mode: str, run_id: str): | ||
| for name in ["heartbeat.json", "heartbeat_final.json"]: | ||
| p = BASE / site / mode / run_id / name | ||
| if p.exists(): | ||
| p = safe_path(site, mode, run_id, name) | ||
| if p is not None and p.exists(): | ||
| return read_text(p) | ||
| return "" | ||
|
|
||
| @app.get("/console/{site}/{mode}/{run_id}", response_class=PlainTextResponse) | ||
| def console(site: str, mode: str, run_id: str): | ||
| p1 = BASE / site / mode / run_id / "nohup.out" | ||
| p2 = BASE / site / mode / run_id / "local_training_console_output.txt" | ||
| if p1.exists(): | ||
| p1 = safe_path(site, mode, run_id, "nohup.out") | ||
| p2 = safe_path(site, mode, run_id, "local_training_console_output.txt") | ||
| if p1 is not None and p1.exists(): | ||
| return read_text(p1) | ||
| if p2.exists(): | ||
| if p2 is not None and p2.exists(): | ||
| return read_text(p2) | ||
| return "" | ||
|
|
||
| @app.get("/log/{site}/{mode}/{run_id}", response_class=PlainTextResponse) | ||
| def log(site: str, mode: str, run_id: str): | ||
| return read_text(BASE / site / mode / run_id / "log.txt") | ||
| p = safe_path(site, mode, run_id, "log.txt") | ||
| if p is None: | ||
| return "" | ||
| return read_text(p) |
| def heartbeat(site: str, mode: str, run_id: str): | ||
| for name in ["heartbeat.json", "heartbeat_final.json"]: | ||
| p = BASE / site / mode / run_id / name | ||
| if p.exists(): |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 5 days ago
In general, to fix uncontrolled path usage, you should normalize any path that includes user-controlled components and then verify that the resulting path is still within an expected safe root directory. For Python paths built with pathlib.Path, this means constructing the path under BASE, resolving or normalizing it (e.g., via .resolve()), and then checking that the resolved path is a descendant of BASE. If the check fails, the endpoint should return an error instead of touching the filesystem.
For this codebase, the best way to fix the problem without changing existing functionality is:
-
Add a small helper function, e.g.
safe_join, that:- Takes a base
Pathand one or more path components (*parts). - Constructs the full path with
base.joinpath(*parts). - Resolves it to an absolute path (
.resolve()). - Verifies that this resolved path is within
baseusingis_relative_to(Python 3.9+) or a fallback usingrelative_toand catchingValueError. - Raises
HTTPException(status_code=404)or similar if the check fails.
- Takes a base
-
Use this helper in all three endpoints (
heartbeat,console, andlog) instead of directly doingBASE / site / mode / run_id / .... This ensuressite,mode, andrun_idcannot cause traversal outsideBASE, addressing all three alert variants in one place. Existing behavior for valid paths (underBASE) remains the same. -
Since we want to return HTTP-friendly errors, we should import
HTTPExceptionfromfastapi. No other behavioral changes are needed; we keep the same return types (PlainTextResponse) and keep usingread_text.
Concretely, inside server_tools/app.py:
- Add
from fastapi import HTTPExceptionto the imports. - Define
safe_joinjust belowBASE/appor nearread_text. - Update:
- In
heartbeat, changep = BASE / site / mode / run_id / nametop = safe_join(BASE, site, mode, run_id, name). - In
console, changep1 = BASE / site / mode / run_id / "nohup.out"andp2 = ...similarly to usesafe_join. - In
log, changeread_text(BASE / site / mode / run_id / "log.txt")to usesafe_joinfor the path argument.
- In
| @@ -1,12 +1,31 @@ | ||
| from pathlib import Path | ||
| import json | ||
| from datetime import datetime, timezone | ||
| from fastapi import FastAPI | ||
| from fastapi import FastAPI, HTTPException | ||
| from fastapi.responses import HTMLResponse, PlainTextResponse | ||
|
|
||
| BASE = Path("/srv/mediswarm/live") | ||
| app = FastAPI() | ||
|
|
||
| def safe_join(base: Path, *parts: str) -> Path: | ||
| """ | ||
| Safely join user-controlled path components to a base directory. | ||
| Ensures the resulting path is within the base directory. | ||
| """ | ||
| candidate = base.joinpath(*parts).resolve() | ||
| try: | ||
| # Python 3.9+: use is_relative_to if available | ||
| is_relative = getattr(candidate, "is_relative_to", None) | ||
| if callable(is_relative): | ||
| if not candidate.is_relative_to(base): | ||
| raise HTTPException(status_code=404) | ||
| else: | ||
| candidate.relative_to(base) | ||
| except ValueError: | ||
| # candidate is not under base | ||
| raise HTTPException(status_code=404) | ||
| return candidate | ||
|
|
||
| def read_text(p: Path, limit: int = 50000) -> str: | ||
| if not p.exists(): | ||
| return "" | ||
| @@ -91,15 +105,15 @@ | ||
| @app.get("/heartbeat/{site}/{mode}/{run_id}", response_class=PlainTextResponse) | ||
| def heartbeat(site: str, mode: str, run_id: str): | ||
| for name in ["heartbeat.json", "heartbeat_final.json"]: | ||
| p = BASE / site / mode / run_id / name | ||
| p = safe_join(BASE, site, mode, run_id, name) | ||
| if p.exists(): | ||
| return read_text(p) | ||
| return "" | ||
|
|
||
| @app.get("/console/{site}/{mode}/{run_id}", response_class=PlainTextResponse) | ||
| def console(site: str, mode: str, run_id: str): | ||
| p1 = BASE / site / mode / run_id / "nohup.out" | ||
| p2 = BASE / site / mode / run_id / "local_training_console_output.txt" | ||
| p1 = safe_join(BASE, site, mode, run_id, "nohup.out") | ||
| p2 = safe_join(BASE, site, mode, run_id, "local_training_console_output.txt") | ||
| if p1.exists(): | ||
| return read_text(p1) | ||
| if p2.exists(): | ||
| @@ -108,4 +116,4 @@ | ||
|
|
||
| @app.get("/log/{site}/{mode}/{run_id}", response_class=PlainTextResponse) | ||
| def log(site: str, mode: str, run_id: str): | ||
| return read_text(BASE / site / mode / run_id / "log.txt") | ||
| return read_text(safe_join(BASE, site, mode, run_id, "log.txt")) |
| def console(site: str, mode: str, run_id: str): | ||
| p1 = BASE / site / mode / run_id / "nohup.out" | ||
| p2 = BASE / site / mode / run_id / "local_training_console_output.txt" | ||
| if p1.exists(): |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 5 days ago
In general, to fix uncontrolled path usage you should normalize the constructed path and enforce that it stays within an allowed root directory (here BASE), or validate the individual path components against an allow‑list or strict pattern. Normalization is important to neutralize .. segments and similar tricks before comparing paths.
For this codebase, the least invasive fix that preserves existing functionality is:
- Add a small helper function that:
- Takes
site,mode, andrun_id. - Builds a path rooted at
BASE. - Resolves it to an absolute path (e.g.
Path.resolve(strict=False)). - Verifies that the resolved path is still under
BASE(e.g. usingPath.relative_toor a prefix check on resolved absolute paths). - Raises an HTTP 404/400 (or similar) if validation fails.
- Takes
- Use this helper in all three endpoints (
/heartbeat,/console,/log) so the validation is centralized and fixes all CodeQL variants at once.
Concretely, within server_tools/app.py:
- Import
HTTPExceptionfromfastapi(you’re already importing fromfastapi, so this is consistent). - Define a function like
get_run_dir(site: str, mode: str, run_id: str) -> Pathjust after theBASE/appdefinitions or nearrows(). This function should:- Compute
candidate = BASE / site / mode / run_id. - Compute
resolved_base = BASE.resolve()once (can be global) andresolved_candidate = candidate.resolve(). - Check
resolved_candidate.is_dir()(optional but safer). - Ensure
resolved_candidateis underresolved_base, for example by:try: resolved_candidate.relative_to(resolved_base) except ValueError: raise HTTPException(status_code=404, detail="Not found")
- Return
resolved_candidate.
- Compute
- Update:
heartbeat()to callrun_dir = get_run_dir(site, mode, run_id)and then look for heartbeat files underrun_dir.console()to call the same helper and then buildp1/p2fromrun_dir.log()to call the same helper and thenread_text(run_dir / "log.txt").
This keeps behavior the same for valid inputs, but rejects any attempt to escape BASE via .. or absolute paths, addressing all three tainted parameters in one place.
| @@ -1,12 +1,25 @@ | ||
| from pathlib import Path | ||
| import json | ||
| from datetime import datetime, timezone | ||
| from fastapi import FastAPI | ||
| from fastapi import FastAPI, HTTPException | ||
| from fastapi.responses import HTMLResponse, PlainTextResponse | ||
|
|
||
| BASE = Path("/srv/mediswarm/live") | ||
| app = FastAPI() | ||
|
|
||
| def get_run_dir(site: str, mode: str, run_id: str) -> Path: | ||
| """ | ||
| Construct and validate a run directory path rooted at BASE. | ||
| Raises HTTPException if the resulting path is outside BASE. | ||
| """ | ||
| base_resolved = BASE.resolve() | ||
| candidate = (BASE / site / mode / run_id).resolve() | ||
| try: | ||
| candidate.relative_to(base_resolved) | ||
| except ValueError: | ||
| raise HTTPException(status_code=404, detail="Not found") | ||
| return candidate | ||
|
|
||
| def read_text(p: Path, limit: int = 50000) -> str: | ||
| if not p.exists(): | ||
| return "" | ||
| @@ -90,16 +98,18 @@ | ||
|
|
||
| @app.get("/heartbeat/{site}/{mode}/{run_id}", response_class=PlainTextResponse) | ||
| def heartbeat(site: str, mode: str, run_id: str): | ||
| run_dir = get_run_dir(site, mode, run_id) | ||
| for name in ["heartbeat.json", "heartbeat_final.json"]: | ||
| p = BASE / site / mode / run_id / name | ||
| p = run_dir / name | ||
| if p.exists(): | ||
| return read_text(p) | ||
| return "" | ||
|
|
||
| @app.get("/console/{site}/{mode}/{run_id}", response_class=PlainTextResponse) | ||
| def console(site: str, mode: str, run_id: str): | ||
| p1 = BASE / site / mode / run_id / "nohup.out" | ||
| p2 = BASE / site / mode / run_id / "local_training_console_output.txt" | ||
| run_dir = get_run_dir(site, mode, run_id) | ||
| p1 = run_dir / "nohup.out" | ||
| p2 = run_dir / "local_training_console_output.txt" | ||
| if p1.exists(): | ||
| return read_text(p1) | ||
| if p2.exists(): | ||
| @@ -108,4 +111,5 @@ | ||
|
|
||
| @app.get("/log/{site}/{mode}/{run_id}", response_class=PlainTextResponse) | ||
| def log(site: str, mode: str, run_id: str): | ||
| return read_text(BASE / site / mode / run_id / "log.txt") | ||
| run_dir = get_run_dir(site, mode, run_id) | ||
| return read_text(run_dir / "log.txt") |
| p2 = BASE / site / mode / run_id / "local_training_console_output.txt" | ||
| if p1.exists(): | ||
| return read_text(p1) | ||
| if p2.exists(): |
Check failure
Code scanning / CodeQL
Uncontrolled data used in path expression High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 5 days ago
In general, to fix uncontrolled path usage you should ensure that any path derived from user input is (a) normalized to remove .. and similar constructs and (b) verified to remain inside an expected base directory, rejecting requests that resolve outside that base. This can be implemented by constructing the full path with the base directory, normalizing it, and then checking that it is still within the base directory before using it.
For this specific code, the best fix with minimal behavior change is:
- Add a helper function, e.g.
safe_join, that:- Takes
base: Pathand a variable number of string path components. - Constructs a path with
base.joinpath(*parts). - Normalizes it with
.resolve()(oros.path.realpath) to eliminate.., symlinks, and redundant separators. - Verifies that the resolved path is still within
baseusingpath.is_relative_to(base)(Python 3.9+) or an equivalent prefix check on.resolve(). - Raises an HTTP 404/400 (using FastAPI’s
HTTPException) if the path escapes the base directory.
- Takes
- Use this helper when building
p1,p2, and thelogpath in theconsoleandlogendpoints, and similarly for theheartbeatendpoint’sppath. - Import
HTTPExceptionandstatusfromfastapito provide appropriate error responses.
Concretely, within server_tools/app.py:
- Add
from fastapi import FastAPI, HTTPException, status(replacing the existingFastAPI-only import) or add a separate import line forHTTPExceptionandstatus. - Define a
safe_joinhelper near the top of the file (afterBASEand before the endpoints). - Replace direct
BASE / site / mode / run_id / ...constructions withinheartbeat,console, andlogwith calls tosafe_join(BASE, site, mode, run_id, filename).
This preserves existing functionality (valid paths under /srv/mediswarm/live still work) while blocking directory traversal and similar abuses across all alert variants in one place.
Five models added
still being tesed
auto collection of logs is working