Skip to content

Commit 595bf22

Browse files
authored
Fix: Issue 195 path safety tests (#203)
I tested this against localhost and made some regression tests to ensure this sitatuion does not come up. The slashes in clob/blob have been ongoing points of interest and i'm hoping with proper tests we can ensure they are stable going forward. Primarily - leading / is fixed ## Design Decision Before we merge this in, need to make a design decision either in a followup PR or this one. Because traversal and absolute-style destination cases are still unsafe. The problem was that CDA accepted ../... IDs, and the current CLI wrote them outside the nested working directory. This means a user could just about write files anywhere on a given host doing this. Do we enforce relative paths? OR Do we allow absolute, UNC paths, or C: type paths but prompt an alert letting a user know it is happening? Do we agree that no `--dest` will fall back to the clob-id / blob-id path? We had some sidechatter here: - #73 (comment) Suggest we iron down what we wish the behavior to be, document that, and merge this in with the final behavior if we agree!
1 parent 009d33c commit 595bf22

6 files changed

Lines changed: 409 additions & 40 deletions

File tree

cwmscli/commands/blob.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@
1111

1212
from cwmscli.utils import (
1313
colors,
14+
format_local_download_error,
1415
get_api_key,
1516
has_invalid_chars,
1617
init_cwms_session,
1718
log_scoped_read_hint,
19+
validate_default_download_dest,
1820
)
1921
from cwmscli.utils.click_help import DOCS_BASE_URL
2022
from cwmscli.utils.deps import requires
@@ -109,6 +111,14 @@ def _save_blob_content(
109111
return dest
110112

111113

114+
def _default_download_dest(blob_id: str) -> str:
115+
return validate_default_download_dest(
116+
blob_id,
117+
resource_name="Blob",
118+
docs_url=BLOB_DOCS_URL,
119+
)
120+
121+
112122
def _blob_media_type(cwms_module, office: str, blob_id: str) -> Optional[str]:
113123
try:
114124
result = cwms_module.get_blobs(office_id=office, blob_id_like=blob_id)
@@ -602,7 +612,7 @@ def download_cmd(
602612

603613
try:
604614
blob_content = cwms.get_blob(office_id=office, blob_id=bid)
605-
target = dest or bid
615+
target = dest or _default_download_dest(bid)
606616
_save_blob_content(
607617
blob_content,
608618
dest=target,
@@ -621,14 +631,15 @@ def download_cmd(
621631
)
622632
sys.exit(1)
623633
except Exception as e:
624-
logging.error(f"Failed to download: {e}")
625-
log_scoped_read_hint(
626-
credential_kind=credential_kind,
627-
anonymous=anonymous,
628-
office=office,
629-
action="download",
630-
resource="blob content",
631-
)
634+
logging.error(format_local_download_error(e, BLOB_DOCS_URL))
635+
if not isinstance(e, (OSError, ValueError)):
636+
log_scoped_read_hint(
637+
credential_kind=credential_kind,
638+
anonymous=anonymous,
639+
office=office,
640+
action="download",
641+
resource="blob content",
642+
)
632643
sys.exit(1)
633644

634645

cwmscli/commands/clob.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@
99
import requests
1010
from cwms import api as cwms_api
1111

12-
from cwmscli.utils import get_api_key, has_invalid_chars, log_scoped_read_hint
12+
from cwmscli.utils import (
13+
format_local_download_error,
14+
get_api_key,
15+
has_invalid_chars,
16+
log_scoped_read_hint,
17+
validate_default_download_dest,
18+
)
1319

1420

1521
def _join_api_url(api_root: str, path: str) -> str:
@@ -29,6 +35,10 @@ def _write_clob_content(content: str, dest: str) -> str:
2935
return dest
3036

3137

38+
def _default_download_dest(clob_id: str) -> str:
39+
return validate_default_download_dest(clob_id, resource_name="Clob")
40+
41+
3242
def _clob_endpoint_id(clob_id: str) -> tuple[str, Optional[str]]:
3343
normalized = clob_id.upper()
3444
if has_invalid_chars(normalized):
@@ -198,7 +208,7 @@ def download_cmd(
198208
content = str(payload)
199209
else:
200210
content = _get_special_clob_text(office=office, clob_id=query_id)
201-
target = dest or bid
211+
target = dest or _default_download_dest(bid)
202212
_write_clob_content(content, target)
203213
logging.info(f"Downloaded clob to: {target}")
204214
except requests.HTTPError as e:
@@ -213,14 +223,7 @@ def download_cmd(
213223
)
214224
sys.exit(1)
215225
except Exception as e:
216-
logging.error(f"Failed to download: {e}")
217-
log_scoped_read_hint(
218-
api_key=resolved_api_key,
219-
anonymous=anonymous,
220-
office=office,
221-
action="download",
222-
resource="clob content",
223-
)
226+
logging.error(format_local_download_error(e, ""))
224227
sys.exit(1)
225228

226229

cwmscli/utils/__init__.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging as py_logging
2+
import re
23
import time
34
from pathlib import Path
45
from typing import Optional, Union
@@ -235,6 +236,66 @@ def log_scoped_read_hint(
235236
)
236237

237238

239+
def format_local_download_error(error: Exception, docs_url: str) -> str:
240+
if isinstance(error, (OSError, ValueError)):
241+
message = (
242+
f"{colors.c('Failed to download:', 'red', bright=True)} {error}. "
243+
f"If this is a local destination/path issue, pass "
244+
f"{colors.c('--dest', 'cyan', bright=True)} explicitly."
245+
)
246+
if docs_url:
247+
message = (
248+
f"{message} {colors.c('Docs:', 'blue', bright=True)} "
249+
f"{colors.c(docs_url, 'blue', bright=True)}"
250+
)
251+
return message
252+
return f"{colors.c('Failed to download:', 'red', bright=True)} {error}"
253+
254+
255+
def validate_default_download_dest(
256+
raw_id: str,
257+
*,
258+
resource_name: str,
259+
docs_url: str = "",
260+
) -> str:
261+
if raw_id is None:
262+
raise ValueError(
263+
f"{resource_name} ID must include a non-root destination name. "
264+
f"Pass --dest explicitly if needed."
265+
)
266+
267+
if raw_id.startswith("//") or raw_id.startswith("\\\\"):
268+
raise ValueError(
269+
f"{resource_name} ID must resolve to a relative local path. "
270+
f"Pass --dest explicitly if needed."
271+
)
272+
273+
target = raw_id.lstrip("/\\")
274+
if not target:
275+
message = (
276+
f"{resource_name} ID must include a non-root destination name. "
277+
f"Pass --dest explicitly if needed."
278+
)
279+
if docs_url:
280+
message = f"{message} Docs: {docs_url}"
281+
raise ValueError(message)
282+
283+
if re.match(r"^[A-Za-z]:", target):
284+
raise ValueError(
285+
f"{resource_name} ID must resolve to a relative local path. "
286+
f"Pass --dest explicitly if needed."
287+
)
288+
289+
parts = re.split(r"[\\/]", target)
290+
if any(part in {"", ".", ".."} for part in parts):
291+
raise ValueError(
292+
f"{resource_name} ID must resolve to a relative local path. "
293+
f"Pass --dest explicitly if needed."
294+
)
295+
296+
return target
297+
298+
238299
def common_api_options(f):
239300
f = log_level_option(f)
240301
f = office_option(f)

0 commit comments

Comments
 (0)