Skip to content

fix: validate hostnames and paths built from scenario metadata - #154

Open
adamsrnmsu wants to merge 1 commit into
sandialabs:mainfrom
adamsrnmsu:fix-metadata-path-validation
Open

adamsrnmsu wants to merge 1 commit into
sandialabs:mainfrom
adamsrnmsu:fix-metadata-path-validation

Conversation

@adamsrnmsu

@adamsrnmsu adamsrnmsu commented Oct 1, 2026 •

Copy link
Copy Markdown

Description

Apps and SCORCH components build host-side file paths from scenario metadata (hostnames, device names, filenames reported by VMs) without checking those values. A value containing / or .. can write outside the experiment directory. This PR validates those values before they reach the filesystem.

Two new helpers in common/utils.py:

  • validate_hostname() applies phenix core's node-name rules as of v2026.10.02: 2 to 63 letters, digits and interior hyphens, not all digits, and not all or phenix. external_node hosts exist only in scenario metadata and never pass through core's check.
  • safe_join() joins parts onto a base and rejects any result that resolves outside it, including an absolute part that would replace the base.

Where they are used:

  • AppBase.add_node, caldera, helics, ignition, otsim, protonuke, scale (plus the builtin and wind_turbine plugins), sceptre and wireguard
  • the cc recv destination, ssh SFTP downloads and providerdata config fetches
  • mm_send and mm_recv, which keep VM-side paths inside the mount; mm_recv also requires a normalized absolute host destination

Other changes to generated files:

  • protonuke args and wireguard fields may not contain newlines.
  • wireguard configs, which hold the private key, are written 0600.
  • The sceptre Windows startup scripts drop from 0777 to 0755.

User-visible constraints: the Scale hostname_prefix, the wind turbine name and the Ignition device name are limited to hostname-safe characters. The READMEs and CHANGELOG (### Security) are updated.

Related Issues/PRs

One of four independent PRs: #152, #153, #155. Each is a single commit on main and they can merge in any order. #155 also adds a ### Security section to CHANGELOG.md, so whichever of the two merges second needs a small rebase. No other files conflict. This PR leaves sunspec/__init__.py alone so it doesn't conflict with #156, which deletes it.

Type of Change

  • Bugfix (fix)

Checklist

  • This PR conforms to the process detailed in the Contributing Guide.
  • I have included no proprietary/sensitive information in my code or the PR.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have made corresponding changes to the documentation.
  • I have tested my code (describe below).

Testing

make check and make test in src/python on Python 3.12: 741 passed. The new common/tests/test_path_helpers.py covers hostname acceptance and rejection, safe_join containment (including absolute parts and .. that stays inside the base), and the mm_recv destination guard. The scale and wind_turbine tests now check the files written to disk instead of mocking open.

Additional Notes

Files touched here, including all of common/utils.py, also move from os.path to pathlib, since safe_join() returns a Path. utils.abs_path() now always returns a Path; with a relative path it used to return a str, which matters to any out-of-tree caller that concatenates strings onto it.

🤖 Generated with Claude Code

https://claude.ai/code/session_016KAfcDUSerQCCWwBM9BxAo

@GhostofGoes GhostofGoes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work, a good security and sanity pass. A few nits. Also make sure you test :)

Comment thread CHANGELOG.md
- **SCEPTRE App**: A `fep` without a mgmt interface raised `UnboundLocalError`, or reused the previous fep's endpoints.
- **SCEPTRE App**: A historian on a subnet with no OPC server was configured with an unrelated OPC's tag list and no address to collect from. It now gets no tags and a warning naming the subnet.

### Security

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup the slop

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trimmed.

@field_validator("name")
@classmethod
def _validate_device_name(cls, v: str | None) -> str | None:
if v is not None and not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_ -]{0,62}", v):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should also verify that they are a minimum of 2 characters long and aren't a reserved name like 'all' or 'phenix', following the state of phenix validation as of phenix 2026.10.02

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validate_hostname() now follows phenix v2026.10.02: 2 to 63 letters, digits and interior hyphens (no _), not all digits, and not all or phenix. Ignition device names get the same 2-character minimum and reserved names, but they keep spaces because Ignition uses them as display names. Note that rejecting phenix everywhere is stricter than core, which only errors on it for Windows nodes and warns otherwise. I can relax that if you prefer.

from phenix_apps.common.logger import logger


def _check_fetch_path(path: str) -> str:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

os.pathsep?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with PurePosixPath(path).parts instead. os.pathsep is the PATH-list separator (:), and os.sep is the host separator, while this is a path inside the guest.

@adamsrnmsu
adamsrnmsu force-pushed the fix-metadata-path-validation branch from 1d30bbb to 661f52e Compare October 4, 2026 23:53
Apps and SCORCH components build host-side file paths from scenario metadata
(hostnames, device names, filenames from VMs) without checking them, so a value
containing '/' or '..' could write outside the experiment directory.

Add two shared helpers in common/utils.py:

- validate_hostname(): phenix core's node-name rules as of v2026.10.02
  (2 to 63 letters, digits and interior hyphens, not all digits, not 'all'
  or 'phenix'). external_node hosts in particular only exist in scenario
  metadata and bypass core's check.
- safe_join(): joins parts onto a base and rejects a result that resolves
  outside it, including absolute parts that would replace the base.

Use them in AppBase.add_node, caldera, helics, ignition, otsim, protonuke,
scale and its plugins, sceptre, wireguard, and the cc, ssh and providerdata
SCORCH components. mm_send/mm_recv keep VM-side paths inside the mount and
mm_recv requires a normalized absolute host destination.

Generated configs also stop accepting embedded newlines (protonuke args,
wireguard fields), wireguard configs holding the private key are written
0600, and the sceptre Windows startup scripts drop from 0777 to 0755.

Files touched here, including all of common/utils.py, also move from
os.path to pathlib, since safe_join returns a Path. utils.abs_path() now
always returns a Path. test_path_helpers.py pins the helpers' behavior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016KAfcDUSerQCCWwBM9BxAo
@adamsrnmsu
adamsrnmsu force-pushed the fix-metadata-path-validation branch from 661f52e to 86f26a9 Compare October 5, 2026 00:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants