Constrain the request fields that become directory names, and stop publishing /datasets/import-roots - #38
Conversation
Four request fields become a directory name without being constrained to
one: `dataset_id` on POST /datasets/import-builtin, `version` and
`base_version` on POST /datasets/{id}/preprocess (and the `version` query
on /analysis and /diagnostics), and `quantization.label` on POST /runs.
Three of them were already rejected by a `Slug` on the model that is
built from them, but only after the directory had been created and its
files written: `register_builtin_dataset` copies the built-in's seven
files at workspace.py:329-331 and validates `DatasetManifest` at :337,
and `_materialise` writes nine split files before `DatasetVersion` at
datasets.py:340. The caller saw a 500 and the data was already outside
the workspace. A check that runs after the write is not a boundary.
Neither `Path.__truediv__` nor `os.path.join` is concatenation: both
discard everything before an absolute component, so an absolute value
relocated the write outright rather than merely traversing upwards. For
`quantization.label` that means out of the run directory the supervisor's
`cwd` and `run_in_directory` exist to confine writes to.
The contracts now type all four `Slug`, and `Workspace.dataset_dir`,
`Workspace.dataset_version_dir` and `datasets._materialise` re-check
through a new `checked_identifier` helper. Putting the second check at
those choke points covers the other callers instead of these routes
alone. Existing values are unaffected: labels in the tree are "" and
"ci_quant", and the previous max_length=64 caps are kept alongside the
pattern so nothing is relaxed.
Each refusal gets a negative test in the S13 suite, the threat model
gains the row, and the resulting patterns are exported to the committed
OpenAPI contract.
`policy.ROUTES` is the only gate on which local routes the public multi-tenant app proxies, and its header says new desktop routes are not automatically published. A dataset id contains no separator, so the `/datasets/<SLUG>` pattern written for `dataset_get` also matches the static sibling route `/datasets/import-roots`. FastAPI dispatches by specificity to `import_roots`, so the allowlist authorised one endpoint and the router served another, with the route never appearing in anyone's list. It answers with absolute host paths; under the web runtime that is `<root>/sessions/<identifier>/workspace/imports`. Keeping exactly that out of responses is why runtime.py sets `app.state.workspace_label`, so `/system/capabilities` returns the label instead of `str(ws.root)`. `DatasetImportBoundary` names this path too, but it only blocks when custom datasets are disabled and the web runtime leaves them enabled, so the allowlist was the only remaining control. Routes that must never be published are now listed in `NEVER_PUBLIC`. The test is the durable half: it walks the real application and fails whenever a static route is reachable only through an id pattern, so the next one cannot slip through unlisted.
gaochangw
left a comment
There was a problem hiding this comment.
Reviewed 0844353 against the request and filesystem boundaries. The identifier checks now precede materialization, quantization output names are constrained, and the explicit deny plus route-surface regression closes the import-roots disclosure. The six regression cases also pass in the 2.2.6 working tree, alongside its broader Python regression. All required checks on this PR are green; no blocking findings in this change.
The separately identified GPU-maintenance failure is also reproduced in the 2.2.6 follow-up: it now fails health/admission closed while tenant expiry continues. Bootstrap documentation is being corrected to describe its intentional launcher-lifetime secret, and CSRF comparison is being hardened there.
Four request fields end up as a directory name without being constrained to one, and one local route is reachable from the public app without anyone having listed it. All four were reproduced by running the code, not by reading it.
What I found
dataset_idonPOST /datasets/import-builtin, andversion/base_versiononPOST /datasets/{id}/preprocess, are plain strings on the request models. TheSlugthat rejects them sits on the stored model, which is built after the directory has been created and the files copied in. So the caller gets a 500 and the data is already on disk: seven built-in CSVs for the first, nine split files for the second.quantization.labelonPOST /runshas no pattern at all. It gets joined into the run'ssave/andlog/paths inmodules/paths.py, so it lands outside the run directory thatcwd=run_dirandrun_in_directoryexist to hold writes inside./datasets/import-rootsis itself a validSLUG, so the/datasets/<id>pattern inweb/policy.pymatches it and the public app proxies it. FastAPI dispatches toimport_roots, which replies with absolute host paths. That is the thingapp.state.workspace_labelwas added to keep out of/system/capabilities.Worth stating plainly, because it changes the severity: neither
Path.__truediv__noros.path.joinis concatenation. Both discard everything before an absolute component, so an absolute value relocates the write outright instead of walking up with../. The read side is disclosure only, not code execution, since everynp.loadon that path already passesallow_pickle=False.What changed
Slugin the request contracts. The existingmax_length=64caps stay alongside the new patterns, so nothing is loosened.Workspace.dataset_dir,Workspace.dataset_version_diranddatasets._materialisere-check through a newchecked_identifier. I put it at those choke points rather than in the handlers so the other callers are covered too. That is what caught theversioncase after I had already fixeddataset_id, and it also happens to closeGET /datasets/{id}/sample-counts.policy.NEVER_PUBLIC. The test next to it is the part I would keep: it walks the real app and fails if any static route is reachable only through an id pattern, so the next one cannot slip in unnoticed.How it was checked
Six new tests, in the S13 suite and
tests/unit/test_public_policy_surface.py. I reverted each fix and confirmed the matching test fails, so none of them pass by accident.openapi.jsoncarries only the six lines this actually changes. I did not regenerate it: my pydantic emitscontentMediaTypewhere the committed file hasformat, plus two extraValidationErrorkeys, and that churn would have buried the real diff.Full run of
tests/unitandtests/integrationon this branch and on unmodifiedd7f0837:684 passed, 1 skipped, 5 failedon this branch against678 passed, 1 skipped, 5 failedond7f0837. Same five failures, by name, on both:test_openapi_is_exportable_and_committed(the pydantic version skew above)test_launcher_shutdown.py::test_window_shutdown_...andtest_launcher_browser_fallback.py::test_browser_failure_...(both block in a headless shell; the first sits in_block_until_interrupted)test_public_studio.py::test_real_training_is_private_...andtest_gpu_bridge.py::test_private_gpu_lease_...The +6 is exactly the new tests. Environment: Linux, Python 3.12, torch 2.14.0+cpu, pytest 8.4.2, CPU only,
--timeout=200 --timeout-method=signal.Behaviour change for callers: requests that used to get a 500 (
dataset_id,version) or be accepted (label) now get a 422. All of them were outside the documented contract.Left out on purpose
Found in the same pass, but each is a separate concern. Happy to send any of them as its own PR.
TenantManager.sweepcallsself.gpu.sweep()outside thetry/except, andmaintenance()has no handler of its own. AnOSErrorout ofGpuBroker.finish(it opensjob.root/"logs"/"worker.log"guarded only byjob.root.exists()) ends the cleanup task whilecleanup_healthystaysTrue, so/healthzkeeps answering 200 and expired workspaces stop being deleted. Only reachable with the GPU bridge configured.SessionStore.exchangenever clearsbootstrap_token, though the module docstring calls it one-time.require_csrfcompares tokens with!=rather thanhmac.compare_digest, unlikeexchangea few lines up.Rollback
Revert either commit on its own; they touch disjoint files apart from their own tests. No migration and no stored state involved.