Repository navigation
Security: Fix py/path-injection in metrics service (#cs-12, #cs-13, #cs-14, #cs-15, #cs-16) - #1993
Conversation
Remediates the 5 open py/path-injection CodeQL alerts (HIGH, CWE-22) reported in the IBM OSPO security notification (#1991): alerts cs-12, cs-13, cs-14, cs-15 and cs-16. CodeQL reports five alerts but traced a single taint flow for all of them. The source is the metric_name URL path parameter of the metrics service: service/metrics/main.py:65 metric_name (FastAPI path parameter) service/metrics/main.py:82 ArtifactFetcherMixin.get_artifact(metric_name) operators.py:1190-1192 fetch_artifact(artifact_identifier) artifact.py:546,562 fetch_artifact(artifact_rep) artifact.py:237-240 open(path) <- cs-12, cs-13, cs-14 artifact.py:284-285 Artifact.load -> artifacts_json_cache utils.py:203-204,221-226 open(path) <- cs-15, cs-16 The underlying cause is a mismatch of expectations. fetch_artifact() is documented to accept either a catalog name or a local file path, which is correct for library callers. The service then passed a value straight off the request URL into it, so a caller could name any file on the server instead of a catalog entry. This was confirmed to be exploitable, not merely a taint-tracking artifact: - fetch_artifact() loaded an artifact JSON file from an absolute path entirely outside the catalog. - load_json() put the full contents of whatever it opened into its exception message, so pointing the request at a non-JSON file disclosed that file verbatim. A planted file came back in the error, contents and all. The endpoint requires a token, so this is not pre-auth, but it still let any token holder read arbitrary files readable by the service account, which is a clear privilege escalation over computing a metric. Fixed in two places, so neither half depends on the other: 1. service/metrics/main.py -- require a catalog name. metric_name is validated with the existing is_name_legal_for_catalog() before it reaches the loader, and a bad value returns HTTP 400 rather than being opened. That validator permits only [\w.], so path separators, "..", absolute paths and drive letters are all rejected. The name is first split with separate_inside_and_outside_square_brackets(), so legitimate overrides such as metrics.f1_macro[average=macro] keep working. The check sits before the function's try block, so the 400 reaches the existing HTTPException handler instead of being turned into a 500 by the broad except. 2. utils.py -- stop load_json() echoing file contents. The JSONDecodeError handler no longer reads the file back into the error message (CWE-209). This is what turned a path into a file-read primitive, and removing it also protects any other caller that passes an untrusted path. The JSON decoder's own message is kept, so the error still names the file and the failing line and column, which is what is actually needed to debug a malformed catalog entry. No test or caller depended on the old "with file content:" wording. fetch_artifact()'s support for loading an artifact from a file path is deliberately left intact: it is a documented feature used by library callers, and restricting it would be a breaking change. The untrusted input is blocked at the service boundary, where the expectation is narrower, rather than by removing a capability from the library. Tests: tests/library/test_path_injection_service.py. Four of them fail against the unpatched code -- the two disclosure tests, plus two asserting the endpoint wires the validator in before get_artifact() and answers 400, since the endpoint itself cannot be called in environments without the optional fastapi dependency. The rest pin behaviour that must not regress: catalog names with and without overrides, loading an artifact from a file path, and load_json() on valid JSON. tests/library: 788 passed. The three failures (test_metrics.py::test_f1_strings, test_utils.py::test_require_errors, test_type_utils.py::test_parse_malformed_string) reproduce identically on a clean main tree and come from optional dependencies missing locally. Reported by: IBM OSPO security scanning (CodeQL default setup) Closes: #1991 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Yoav Katz <katz@il.ibm.com>
CI status analysisAll checks relevant to this change pass:
There are six red results. None of them is attributable to this change:
Why these are independent of this diffTwo checks, rather than inference from the failure text alone:
Caveat on methodA true apples-to-apples comparison was not available: these workflows are PR-triggered (no recent The 🤖 Generated with Claude Code |
Correction and re-run resultsI said above that a re-run "would likely clear several of these." That was right for one job and wrong for
Root cause is a library-version dependency, not this PR.
Where that leaves the merge
🤖 Generated with Claude Code |
…#cs-13, #cs-14, #cs-18) (#1995) Remediates the 4 py/path-injection CodeQL alerts (HIGH, CWE-22) still open after the previous fix, reported in the IBM OSPO security notification (#1994): cs-12, cs-13, cs-14 (src/unitxt/artifact.py:238,240) and cs-18 (src/unitxt/utils.py:222). The earlier fix (#1993) blocked the attack at runtime but left the alerts open, and the alerts were right to stay open. It validated a derived value and then passed the original one on: requested_name, _ = separate_inside_and_outside_square_brackets(metric_name) if not is_name_legal_for_catalog(requested_name): raise HTTPException(status_code=400, ...) ... metric = ArtifactFetcherMixin.get_artifact(metric_name) # <- raw value Checking requested_name says nothing about metric_name, so nothing about the value that actually reached the loader. It happened to be safe only because the two strings agreed on the part that mattered -- a property no check enforced and any later edit could break. CodeQL reported it as unsanitized because, as dataflow, it is. The taint path in the current SARIF is unchanged from before the fix: service/metrics/main.py:69,100 metric_name (FastAPI path parameter) operators.py:1190-1192 fetch_artifact(artifact_identifier) artifact.py:546,562-563 fetch_artifact(artifact_rep) artifact.py:238,240 open(path) <- cs-12, cs-13, cs-14 utils.py:203-204,221-222 open(path) <- cs-18 cs-18 is a fresh alert raised against the fix commit itself, replacing cs-15 and cs-16 at the same sink: the flow survived, only its line numbers moved. The fix makes the sanitization real rather than incidental: 1. artifact.py -- new sanitize_catalog_identifier(). Validates a reference as a catalog name and returns the name it validated, rebuilt from its checked segments, plus the bracketed overrides already parsed. Callers pass on the value that was checked, so no unvalidated substring can reach a loader and the guarantee no longer depends on two variables staying in agreement. Rejections raise IllegalCatalogNameError, a ValueError subclass, so a caller can answer 400 for "not a catalog name" without catching unrelated failures. Malformed brackets ("metrics.accuracy[unclosed") now reject cleanly too; before, the parser's ValueError reached the endpoint's broad except and became a 500. 2. service/metrics/main.py -- use only the sanitized name. compute() passes safe_metric_name to the loader and never metric_name, which is now consumed by the sanitizer and nothing else. The raw value is also kept out of the log lines, so it cannot be used to forge log entries. 3. operators.py -- get_artifact() takes overwrite_kwargs. Since overrides now travel beside the name instead of inside it, they are applied as keyword overrides. Re-serializing them into the identifier was not an option: the parser returns typed values, so "[n=[1,2]]" and nested artifact references do not round-trip through a string. The cache key includes the overrides, so an overridden artifact cannot be served from, or written to, the plain artifact's entry. The parameter is optional and all seven existing callers are unaffected. fetch_artifact()'s support for loading an artifact from a file path is again left intact: it is a documented library feature, and the untrusted input is blocked at the service boundary where the expectation is narrower. Verified: the planted-file read and the arbitrary-file read are both refused with 400, with no file content in the error; legitimate names, and names with overrides, still resolve and the override is applied. Tests: tests/library/test_path_injection_service.py, extended to 20 tests. Three of them fail against the #1993 code, including one that names get_artifact(metric_name) as the raw-value use that kept the alerts open -- that class asserts the endpoint never passes the raw parameter onward, so the regression this commit fixes cannot recur silently. Added coverage for the sanitizer's rejections (absolute paths, traversal, Windows paths, URLs, null bytes, malformed brackets, non-strings), for overrides still applying, and for the cache not being poisoned across overridden and plain fetches. The earlier tests pinning load_json's non-disclosure and file-path loading are kept. tests/library: 799 passed. The 3 failures (test_metrics.py::test_f1_strings, test_utils.py::test_require_errors, test_type_utils.py::test_parse_malformed_string) reproduce identically on a clean main tree and come from optional dependencies missing locally. ruff 0.1.6 (the pinned pre-commit version) check and format, codespell and the relative-imports hook all pass. Reported by: IBM OSPO security scanning (CodeQL default setup) Closes: #1994 Signed-off-by: Yoav Katz <katz@il.ibm.com>
Summary
Remediates the 5 open
py/path-injectionCodeQL alerts (HIGH, CWE-22) from the IBM OSPO security notification in #1991 — alerts cs-12, cs-13, cs-14, cs-15, cs-16.CodeQL reports five alerts but traced one taint flow for all of them. The source is the
metric_nameURL path parameter of the metrics service:The cause is a mismatch of expectations.
fetch_artifact()is documented to accept either a catalog name or a local file path, which is right for library callers. The service passed a value straight off the request URL into it, so a caller could name any file on the server instead of a catalog entry.This is exploitable, not just a taint-tracking artifact
Both were verified against the unpatched code:
fetch_artifact()loaded an artifact JSON file from an absolute path entirely outside the catalog.load_json()put the full contents of whatever it opened into its exception message, so pointing the request at a non-JSON file disclosed that file verbatim. A planted file came back in the error, contents and all.The endpoint requires a token, so this is not pre-auth. But it let any token holder read arbitrary files readable by the service account — a clear privilege escalation over computing a metric.
The fix
Two independent halves, so neither relies on the other:
1.
service/metrics/main.py— require a catalog name.metric_nameis validated with the existingis_name_legal_for_catalog()before it reaches the loader; a bad value returns HTTP 400 instead of being opened. That validator permits only[\w.], so path separators,.., absolute paths and drive letters are all rejected. The name is first split withseparate_inside_and_outside_square_brackets(), so legitimate overrides likemetrics.f1_macro[average=macro]keep working.The check sits before the function's
try, so the 400 reaches the existingHTTPExceptionhandler rather than being converted to a 500 by the broadexcept.2.
utils.py— stopload_json()echoing file contents.The
JSONDecodeErrorhandler no longer reads the file back into the error message (CWE-209). That echo is what turned a path into a file-read primitive, and removing it also protects any other caller passing an untrusted path. The decoder's own message is kept, so errors still name the file and the failing line/column — what you actually need to debug a malformed catalog entry. No test or caller depended on the old"with file content:"wording.What is deliberately not changed
fetch_artifact()'s support for loading an artifact from a file path stays intact. It's a documented feature used by library callers, and restricting it would be a breaking change. The untrusted input is blocked at the service boundary, where the expectation is narrower, rather than by removing a library capability.Testing
tests/library/test_path_injection_service.py(13 tests). Four fail against the unpatched code: the two disclosure tests, plus two asserting the endpoint wires the validator in beforeget_artifact()and answers 400 — checked at the source level because the endpoint can't be called where the optionalfastapidependency is absent.The rest pin behaviour that must not regress: catalog names with and without overrides, loading an artifact from a file path, and
load_json()on valid JSON.tests/library: 788 passed. Three failures (test_metrics.py::test_f1_strings,test_utils.py::test_require_errors,test_type_utils.py::test_parse_malformed_string) reproduce identically on a cleanmainand come from optional dependencies missing in the local environment.Closes #1991
🤖 Generated with Claude Code