Skip to content

Resolve security review findings#60

Open
brandonc wants to merge 15 commits into
mainfrom
TF-38447-resolve-security-review-findings
Open

Resolve security review findings#60
brandonc wants to merge 15 commits into
mainfrom
TF-38447-resolve-security-review-findings

Conversation

@brandonc

@brandonc brandonc commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Description

  • Enforce profile hostname and https in api argument to subvert token stealing
  • Profile hcl files should have owner-read permissions only
  • Anonymize hostname telemetry when not a known hostname
  • Also updates README with new gif
  • Added --debug logging for diagnosing actual token source

PR Checklist

  • Ensure you have changie installed for release notes prep.
  • Ensure any command changes are sensitive to these global flags:
    • --json — Force machine readable output to stdout. Does not apply to stderr.
    • --markdown — Force markdown output to stdout. Does not apply to stderr.
    • --dry-run — Don't make any actual writes or other mutations. Describe what would have changed to stderr.
    • --quiet — Don't render output to stdout.
  • Get the logging interface from the context and add debug logging for interesting conditions and nonfatal situations.
  • Run make gen/screenshot if the root command output changes.
  • Add the Autocomplete field to positional arguments and flags to assist shell autocomplete.
  • Run changie new to prepare a new changelog entry for the next set of release notes.

Comment thread .changes/unreleased/BUG FIXES-20260616-150538.yaml Outdated
Comment thread internal/commands/api/api.go Outdated
Comment thread internal/pkg/profile/profile.go
return fmt.Errorf("invalid input path/URL %q: must be on the same host as the configured profile host %q", path, inv.Profile.GetHostname())
}

if resolvedURL.Scheme != "https" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hostnames are stored exactly as the user entered them (including any scheme), and the client only prepends https:// when no scheme is present also reachable via TFCTL_HOSTNAME. So a profile pointed at an http:// host will now fail every api call, not just absolute http URLs, since relative paths inherit the configured scheme. Is that intended?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think this is a bug in how profile hostnames are parsed and created. "hostname" should not contain a scheme.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Agree this was a real bug. But the TFCTL_HOSTNAME env path still assigns the raw value without validation in loader.go:319-320 it does hostname = envHostname and stores it directly, bypassing ValidateHostname/SetHostname. So TFCTL_HOSTNAME moves forward without any changes. There's already a normalizeHostname helper at loader.go:338 used for token lookups and routing the env value through validation there would help

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Got it

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Comment thread internal/commands/api/api.go Outdated
Comment thread internal/pkg/profile/profile.go
Comment thread internal/commands/api/api.go Outdated
return fmt.Errorf("invalid input path/URL %q", path)
}

if resolvedURL.Host != apiClient.BaseURL.Host {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The same-host check on this line and and https check on 264 live inside the NewCmdAPI closure, so they can't be unit-tested via an Opts value. RunAPI line 379 already receives opts.Client.BaseURL and opts.URL so moving these two checks into RunAPI aligns with our conventions and lets us add a positive-acceptance test (currently only rejection paths are reachable).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I don't want to move these into RunOpts because then I'd have to extend opts to contain profile information. I'm only worried about rejection paths in tests because those are the only possible branching conditions. I can do a normal Run test with valid arguments and get normal results. Long story short, I like the code


if opts.DryRun {
cs := opts.IO.ColorScheme()
fmt.Fprintf(opts.IO.Err(), "%s would set profile property %q to %q\n", cs.DryRunLabel(), opts.Property, opts.Value)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: --dry-run prints the raw opts.Value, but the value that actually gets stored is normalized. The dry-run output should show the normalized hostname so it matches what a real run would write.

// SetHostname sets the profile's hostname after validating it. The hostname should be a hostname
// with an optional port, and should not include a scheme. If the hostname includes a scheme, the
// scheme will be stripped.
func (p *Profile) SetHostname(hostname string) error {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: There is no nil-receiver guard like the one SetDefaultOrganization has

Comment thread internal/pkg/profile/profile.go Outdated
// ValidateHostname validates that the provided hostname is a valid hostname with an optional port,
// and does not include a scheme. If the hostname includes a scheme, the scheme is stripped before
// validation.
func (p *Profile) ValidateHostname(hostname string) (string, error) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

doesn't use p could be a package level function

Comment thread README.md
The `tfctl` CLI provides high-level commands for common workflows, such as managing runs, variables, and workspaces, and direct API access for advanced automation. It supports multiple configuration profiles, allowing you to switch between different HCP Terraform organizations and Terraform Enterprise instances. It also integrates with AI coding agents to facilitate agent-assisted management of Terraform workflows.

![tfctl](assets/hero.png "tfctl")
![tfctl](assets/demo.gif "tfctl demo")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

should we delete the hero.png file

@brandonc brandonc force-pushed the TF-38447-resolve-security-review-findings branch from 828d99e to 4c10c7a Compare June 17, 2026 19:38
}

hostname := DefaultHostname
if envHostname, ok := os.LookupEnv(envVarHostname); ok && envHostname != "" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

TFCTL_HOSTNAME is still stored raw here it skips NormalizeHostname, so the scheme/path-stripping and IDNA work done for create/set doesn't apply to the env override.

if envHostname, ok := os.LookupEnv(envVarHostname); ok && envHostname != "" {
    if normalized, err := NormalizeHostname(envHostname); err == nil {
        hostname = normalized
    }
}


// NormalizeHostname validates and normalizes the given hostname by stripping any extra URL data,
// like paths. It also converts domain names to their idna ASCII form.
func NormalizeHostname(hostname string) (string, error) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

NormalizeHostname("") returns ("", nil) because idna.Lookup.ToASCII("") succeeds and returns before the validHostnamePattern check (line 296). The old ValidateHostname rejected empty. This is reachable via tfctl profile set hostname "", which now passes validation and since a hostname change unconditionally clears credentials silently wipes the token and default_organization. I would suggest rejecting empty explicitly, like at the top:

if strings.TrimSpace(hostname) == "" {
    return "", fmt.Errorf("hostname cannot be empty")
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think this is what we want. Empty hostname restores the default hostname.

Comment thread internal/pkg/profile/loader.go Outdated
if err != nil {
return nil, err
}
logger.Debug("Setting token from terraform credentials file", "hostname", c.Hostname)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This logs "Setting token from terraform credentials file" (and assigns tokenFromEnv) whenever the profile has no token, even when tokenFromCredentials returned "" (no match). Steps 1 and 3 only log inside their if … != "" guards this one should too:

if credsToken != "" {
    logger.Debug("Setting token from terraform credentials file", "hostname", c.Hostname)
    c.tokenFromEnv = credsToken
}

if c.Token == "" {
if envToken := os.Getenv(legacyTokenEnvVar(c.Hostname)); envToken != "" {
// 2. Check for a token in the terraform credentials file that matches the hostname of the profile
if c.GetToken() == "" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

TFCTL_TOKEN now takes precedence over the Terraform credentials file (previously the creds file was checked first) is this intended?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, i think this one is the most relevant

// profile name.
ErrInvalidProfileName = errors.New("profile name may only include a-z, A-Z, 0-9, or '_', must start with a letter, and can be no longer than 64 characters")

validHostnamePattern = regexp.MustCompile(`^[a-zA-Z0-9.-]+(:\d+)?$`)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: validHostnamePattern rejects IPv6 literals and after the refactor those inputs surface the raw url.Parse error ("first path segment in URL cannot contain colon") rather than the "must be a valid hostname" message

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