From 928ba071e213f2c4e89764c99aa45732a43207cd Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Mon, 20 Jul 2026 12:29:12 -0700 Subject: [PATCH 1/3] fix(pat): stop over-specifying PAT shape in error and docs (DEVEX-889) pat add rejected tokens with an error that hinted at a specific gd_pat__ shape GoDaddy doesn't actually guarantee, and the auth guide repeated that assumption. Loosen is_valid_pat to a plain gd_pat_ prefix check, point the rejection error at `gddy guide auth` instead of restating format rules, and sync the guide's PAT lifecycle steps (creation, revocation) with the real Developer Portal flow. --- rust/src/pat/guides/auth.md | 20 ++++++---------- rust/src/pat/mod.rs | 47 ++++++++++++++----------------------- 2 files changed, 24 insertions(+), 43 deletions(-) diff --git a/rust/src/pat/guides/auth.md b/rust/src/pat/guides/auth.md index 95e52c5b..9a67a97b 100644 --- a/rust/src/pat/guides/auth.md +++ b/rust/src/pat/guides/auth.md @@ -21,16 +21,10 @@ Your browser opens to the GoDaddy login screen. After you approve the CLI, an ac ### Creating a PAT -1. Sign in to the GoDaddy Developer Portal. -2. Open **Personal Access Tokens**. -3. Choose a name, the scopes you need, and an expiration (up to 365 days). -4. Copy the plaintext token immediately — it is shown only once. - -The token looks like: - -```text -gd_pat__<8-hex-crc> -``` +1. Sign in to the [Personal Access Token page](https://developer.godaddy.com/personal-access-token). +2. Click **+ Generate Token**. +3. In the **Generate personal access token** dialog, fill in a **Name**, an **Expiration** (in days), and the **Scopes** the token needs (see the [PAT scopes reference](https://developer.godaddy.com/en/docs/api-users/auth#pat-scopes) — e.g. `domains.domain:read`, `domains.dns:update`). A write-scoped token also satisfies reads for the same resource; a read-scoped token is refused on writes. +4. Click **Generate Token**. The token is shown once in a "Copy your new token" dialog — copy it immediately. You can't retrieve it again from the Personal Access Token page; if you lose it, revoke it and generate a new one. ### Storing a PAT in the CLI @@ -62,7 +56,7 @@ Remove the PAT for an environment: gddy pat remove --env prod ``` -Removing the PAT from the CLI does **not** revoke it in the Developer Portal. +Removing the PAT from the CLI does **not** revoke it in the Developer Portal. To revoke it for real, go to the [Personal Access Token page](https://developer.godaddy.com/personal-access-token), click the trash icon next to the token, and confirm. ### Using PATs in CI @@ -104,5 +98,5 @@ The GoDaddy API gateway exchanges the PAT for a short-lived access token and enf - Treat PATs like passwords. Do not commit them to source control. - Prefer `GDDY_PAT_` environment variables in CI over storing PATs in the registry file. -- Regenerate leaked or suspected PATs in the Developer Portal immediately. -- The CLI validates PAT format before storing, but does **not** contact the API to verify a PAT is still active. +- Regenerate leaked PATs on the [Personal Access Token page](https://developer.godaddy.com/personal-access-token) immediately. +- The CLI only validates PAT format before storing, but it does **not** contact the API to verify the PAT is still active or has the scopes you need. diff --git a/rust/src/pat/mod.rs b/rust/src/pat/mod.rs index 3c33d659..49f0256a 100644 --- a/rust/src/pat/mod.rs +++ b/rust/src/pat/mod.rs @@ -44,11 +44,7 @@ output_schema!(PatRemoveResult { }); /// PAT prefix advertised by GoDaddy. A valid PAT starts with this string. -pub const PAT_PREFIX: &str = "gd_pat_"; - -/// The base portion of the advertised prefix, used when validating the -/// `gd_pat__` structure. -const PAT_PREFIX_BODY: &str = "gd_pat"; +const PAT_PREFIX: &str = "gd_pat_"; /// Default env-var PAT applied to any environment. pub const PAT_ENV_VAR: &str = "GDDY_PAT"; @@ -122,21 +118,12 @@ fn save_registry(path: &std::path::Path, registry: &PatRegistry) -> Result<(), C registry.save(path) } -/// Validate that `token` looks like a GoDaddy PAT: `gd_pat__<8 hex crc>`. +/// Validate that `token` looks like a GoDaddy PAT. This only checks for that +/// prefix plus at least one character of content after it so that we are not +/// overly tied to the implementation details of the token format. #[must_use] pub fn is_valid_pat(token: &str) -> bool { - let Some((body, crc)) = token.rsplit_once('_') else { - return false; - }; - if crc.len() != 8 || !crc.chars().all(|c| c.is_ascii_hexdigit()) { - return false; - } - let Some((prefix, entropy)) = body.rsplit_once('_') else { - return false; - }; - prefix == PAT_PREFIX_BODY - && !entropy.is_empty() - && entropy.chars().all(|c| c.is_ascii_alphanumeric()) + token.len() > PAT_PREFIX.len() && token.starts_with(PAT_PREFIX) } /// Returns the last four characters of a PAT. If the token is four characters or @@ -350,9 +337,9 @@ fn add_command() -> RuntimeCommandSpec { let name = string_arg(&ctx.args, "name"); let token = resolve_token_arg(&ctx.args).await?; if !is_valid_pat(&token) { - return Err(CliCoreError::message(format!( - "token does not look like a GoDaddy PAT (expected `{PAT_PREFIX}...`); refusing to store it" - ))); + return Err(CliCoreError::message( + "token doesn't look like a GoDaddy PAT; refusing to store it. Run `gddy guide auth` for details on creating PATs.", + )); } let entry = PatEntry { token, name }; @@ -484,23 +471,23 @@ mod tests { use super::*; #[test] - fn rejects_malformed_pats() { + fn rejects_tokens_without_the_pat_prefix() { assert!(!is_valid_pat("")); assert!(!is_valid_pat("notapat")); - assert!(!is_valid_pat("gd_pat_")); - assert!(!is_valid_pat("gd_pat_abc")); // missing crc - assert!(!is_valid_pat("gd_pat_abc_xyz")); // crc too short - assert!(!is_valid_pat("gd_pat_abc_zzzzzzzz")); // crc not hex - assert!(!is_valid_pat("gd_pat_ab!c_12345678")); // invalid char in entropy + assert!(!is_valid_pat("gd_pat")); // missing trailing underscore + assert!(!is_valid_pat("gd_pat_")); // nothing after the prefix } #[test] - fn accepts_well_formed_pat() { + fn accepts_any_non_empty_value_after_the_prefix() { + // The CLI does not assume a stable shape beyond the prefix, so these + // (including the exact strings from DEVEX-889's bug report) all pass. assert!(is_valid_pat("gd_pat_abc123_1234abcd")); assert!(is_valid_pat("gd_pat_aA0bB1cC2_abcdef12")); - // entropy may be long + assert!(is_valid_pat("gd_pat_1234567890abcdef1234567890abcdef")); + assert!(is_valid_pat("gd_pat_YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo=")); assert!(is_valid_pat( - "gd_pat_abcdefghijklmnopqrstuvwxyz0123456789_abcdef12" + "gd_pat_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH" )); } From 5df0fae628d85e8eba3066a6f0f2059364b58784 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Mon, 20 Jul 2026 12:34:30 -0700 Subject: [PATCH 2/3] fix(pat): reject whitespace-only content after the gd_pat_ prefix An untrimmed env var (e.g. a trailing newline) would otherwise pass is_valid_pat and be sent as a garbage Bearer token, producing a confusing auth failure instead of a clear rejection. --- rust/src/pat/mod.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/rust/src/pat/mod.rs b/rust/src/pat/mod.rs index 49f0256a..d8a8ed7f 100644 --- a/rust/src/pat/mod.rs +++ b/rust/src/pat/mod.rs @@ -118,12 +118,16 @@ fn save_registry(path: &std::path::Path, registry: &PatRegistry) -> Result<(), C registry.save(path) } -/// Validate that `token` looks like a GoDaddy PAT. This only checks for that -/// prefix plus at least one character of content after it so that we are not -/// overly tied to the implementation details of the token format. +/// Validate that `token` looks like a GoDaddy PAT. This only checks for the +/// `gd_pat_` prefix plus at least one non-whitespace character of content +/// after it, so that we are not overly tied to the implementation details of +/// the token format while still rejecting untrimmed env-var values (e.g. +/// `gd_pat_\n`) that would otherwise be sent as a garbage Bearer token. #[must_use] pub fn is_valid_pat(token: &str) -> bool { - token.len() > PAT_PREFIX.len() && token.starts_with(PAT_PREFIX) + token + .strip_prefix(PAT_PREFIX) + .is_some_and(|rest| rest.chars().any(|c| !c.is_whitespace())) } /// Returns the last four characters of a PAT. If the token is four characters or @@ -478,6 +482,15 @@ mod tests { assert!(!is_valid_pat("gd_pat_")); // nothing after the prefix } + #[test] + fn rejects_whitespace_only_content_after_the_prefix() { + // An untrimmed env var (e.g. a trailing newline) should not be treated + // as valid — it would otherwise be sent as a garbage Bearer token. + assert!(!is_valid_pat("gd_pat_ ")); + assert!(!is_valid_pat("gd_pat_\n")); + assert!(!is_valid_pat("gd_pat_\t\t")); + } + #[test] fn accepts_any_non_empty_value_after_the_prefix() { // The CLI does not assume a stable shape beyond the prefix, so these From 3bec066aca0adcc39271cf45dc8ea0829c486bc4 Mon Sep 17 00:00:00 2001 From: Jacob Page Date: Mon, 20 Jul 2026 12:39:31 -0700 Subject: [PATCH 3/3] fix(pat): reject any embedded whitespace after the gd_pat_ prefix Rejecting only whitespace-only tails still let values like gd_pat_abc123\n (e.g. read from a file with a trailing newline) through, which would be sent as a garbage Bearer token instead of failing with a clear error. --- rust/src/pat/mod.rs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/rust/src/pat/mod.rs b/rust/src/pat/mod.rs index d8a8ed7f..4367a80c 100644 --- a/rust/src/pat/mod.rs +++ b/rust/src/pat/mod.rs @@ -119,15 +119,16 @@ fn save_registry(path: &std::path::Path, registry: &PatRegistry) -> Result<(), C } /// Validate that `token` looks like a GoDaddy PAT. This only checks for the -/// `gd_pat_` prefix plus at least one non-whitespace character of content -/// after it, so that we are not overly tied to the implementation details of -/// the token format while still rejecting untrimmed env-var values (e.g. -/// `gd_pat_\n`) that would otherwise be sent as a garbage Bearer token. +/// `gd_pat_` prefix plus at least one character of content after it with no +/// embedded whitespace, so that we are not overly tied to the implementation +/// details of the token format while still rejecting untrimmed values (e.g. +/// `gd_pat_abc\n` from an env var or file with a trailing newline) that would +/// otherwise be sent as a garbage Bearer token. #[must_use] pub fn is_valid_pat(token: &str) -> bool { token .strip_prefix(PAT_PREFIX) - .is_some_and(|rest| rest.chars().any(|c| !c.is_whitespace())) + .is_some_and(|rest| !rest.is_empty() && !rest.chars().any(char::is_whitespace)) } /// Returns the last four characters of a PAT. If the token is four characters or @@ -483,12 +484,17 @@ mod tests { } #[test] - fn rejects_whitespace_only_content_after_the_prefix() { - // An untrimmed env var (e.g. a trailing newline) should not be treated - // as valid — it would otherwise be sent as a garbage Bearer token. + fn rejects_tokens_containing_whitespace_after_the_prefix() { + // An untrimmed env var or file (e.g. a trailing newline) should not be + // treated as valid — it would otherwise be sent as a garbage Bearer + // token. This covers both whitespace-only tails and whitespace + // embedded alongside real-looking content. assert!(!is_valid_pat("gd_pat_ ")); assert!(!is_valid_pat("gd_pat_\n")); assert!(!is_valid_pat("gd_pat_\t\t")); + assert!(!is_valid_pat("gd_pat_abc123\n")); + assert!(!is_valid_pat("gd_pat_ abc123")); + assert!(!is_valid_pat("gd_pat_abc 123")); } #[test]