Skip to content

Commit cec9d88

Browse files
[workers-auth] Validate account IDs before using them in API requests (#13746)
Co-authored-by: Pete Bacon Darwin <pbacondarwin@cloudflare.com>
1 parent cc63aae commit cec9d88

8 files changed

Lines changed: 183 additions & 5 deletions

File tree

.changeset/smart-news-punch.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@cloudflare/workers-auth": patch
3+
"wrangler": patch
4+
---
5+
6+
Report a clear error for account IDs that can't be used in a Cloudflare API request
7+
8+
Account IDs are substituted straight into Cloudflare API URL paths, so a value containing non-ASCII characters previously failed deep inside the request layer with an opaque `Cannot convert argument to a ByteString` error that gave no hint about which setting was at fault. Account IDs read from `CLOUDFLARE_ACCOUNT_ID` and from the `account_id` configuration field are now validated up front, and an invalid value fails with a message naming both the offending value and where it came from.

packages/workers-auth/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ CLIs. Internal-only — published as `prerelease: true`.
1111
- `src/generate-auth-url.ts` — authorize URL builder
1212
- `src/generate-random-state.ts` — CSRF state generator
1313
- `src/env-vars.ts``WRANGLER_*` and `CLOUDFLARE_AUTH_*` env-var getters
14+
- `src/account-id.ts``validateAccountId(id, source)`, applied to every user-supplied account ID (`CLOUDFLARE_ACCOUNT_ID`, the consumer's `account_id` config field) before it reaches an API URL path
1415
- `src/access.ts` — Cloudflare Access detection + service-token / `cloudflared` headers
1516
- `src/config-file/auth.ts` — the `AuthConfigStorage` / `UserAuthConfig` storage contract (interfaces only). The default plaintext credential implementation lives alongside the credential-store layer at `src/credential-store/file-store.ts` (see "Credential storage" below). The core `src/core/auth-config-file.ts` (`createAuthConfigFileHelpers({getConfigPath, format})`) and `src/core/file-storage.ts` (`createFileStorage(format, getPath)`) build the per-profile path helpers and non-credential file stores; each CLI descriptor (`src/wrangler/auth-config-file.ts`, `src/cf/auth-config-file.ts`) binds them to its config dir + `FileFormat` and re-exports `createTomlFileStorage` / `createJsonFileStorage`, `getAuthConfigFilePath`, etc. from its entrypoint. See "CORE LAYER" below.
1617
- `src/config-file/temporary.ts``TemporaryAccountStorage` / `TemporaryPreviewAccount` storage contract for the temporary-preview-account flow
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { UserError } from "@cloudflare/workers-utils";
2+
3+
// Deliberately looser than the 32-character hex IDs the API hands out: the goal
4+
// is to reject values that cannot travel in a request at all, not to second-guess
5+
// what the API will accept.
6+
const ACCOUNT_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
7+
8+
/**
9+
* Validate a user-supplied account ID before it reaches the API layer.
10+
*
11+
* Account IDs are substituted straight into Cloudflare API URL paths, and
12+
* `fetch()` rejects URL/header values that aren't representable as a
13+
* `ByteString`. Without this check, an account ID containing non-ASCII
14+
* characters fails deep inside the request layer with an opaque
15+
* `Cannot convert argument to a ByteString` error that gives no hint about
16+
* which setting is at fault.
17+
*
18+
* @param accountId The account ID to check.
19+
* @param source Where the value came from, phrased to read after the ID —
20+
* e.g. `"set in the \`CLOUDFLARE_ACCOUNT_ID\` environment variable"`.
21+
* @returns The account ID, so this can be used inline.
22+
*/
23+
export function validateAccountId(accountId: string, source: string): string {
24+
if (!ACCOUNT_ID_PATTERN.test(accountId)) {
25+
throw new UserError(
26+
`Invalid account ID "${accountId}" ${source}. Account IDs may only contain alphanumeric characters, hyphens, and underscores.`,
27+
{ telemetryMessage: "user account id invalid characters" }
28+
);
29+
}
30+
31+
return accountId;
32+
}

packages/workers-auth/src/core/factory.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from "@cloudflare/workers-utils";
2828
import { formatDistanceToNowStrict } from "date-fns";
2929
import { dedent } from "ts-dedent";
30+
import { validateAccountId } from "../account-id";
3031
import { createCredentialStorageContext } from "../credential-store";
3132
import { getAuthFromEnv } from "../credentials";
3233
import { getCloudflareAccountIdFromEnv as getAccountIdFromEnv } from "../env-vars";
@@ -479,7 +480,10 @@ Alternatively, try running \`${descriptor.commands.login}\` to re-authenticate.`
479480
}
480481

481482
if (config.account_id) {
482-
return config.account_id;
483+
return validateAccountId(
484+
config.account_id,
485+
`set as \`account_id\` in your ${descriptor.getConfigFileLabel()} file`
486+
);
483487
}
484488
const envAccountId = getAccountIdFromEnv();
485489
if (envAccountId) {

packages/workers-auth/src/env-vars.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
getCloudflareApiEnvironmentFromEnv,
44
getEnvironmentVariableFactory,
55
} from "@cloudflare/workers-utils";
6+
import { validateAccountId } from "./account-id";
67

78
/**
89
* `WRANGLER_AUTH_DOMAIN` is the URL base domain that is used
@@ -59,17 +60,34 @@ export const getRevokeUrlFromEnv = getEnvironmentVariableFactory({
5960
defaultValue: () => `https://${getAuthDomainFromEnv()}/oauth2/revoke`,
6061
});
6162

63+
const readCloudflareAccountIdFromEnv = getEnvironmentVariableFactory({
64+
variableName: "CLOUDFLARE_ACCOUNT_ID",
65+
deprecatedName: "CF_ACCOUNT_ID",
66+
});
67+
6268
/**
6369
* `CLOUDFLARE_ACCOUNT_ID` overrides the account inferred from the current user.
6470
*
6571
* This is a Cloudflare-wide variable (not wrangler-specific), so it lives in the
6672
* shared core rather than a consumer layer. `CF_ACCOUNT_ID` is the deprecated
6773
* spelling.
74+
*
75+
* Every caller feeds the result into a Cloudflare API URL path, so the value is
76+
* validated here rather than at each call site. An empty string is treated as
77+
* unset so callers keep falling back to the cached / interactively selected
78+
* account.
6879
*/
69-
export const getCloudflareAccountIdFromEnv = getEnvironmentVariableFactory({
70-
variableName: "CLOUDFLARE_ACCOUNT_ID",
71-
deprecatedName: "CF_ACCOUNT_ID",
72-
});
80+
export function getCloudflareAccountIdFromEnv(): string | undefined {
81+
const accountId = readCloudflareAccountIdFromEnv();
82+
if (!accountId) {
83+
return undefined;
84+
}
85+
86+
return validateAccountId(
87+
accountId,
88+
"set in the `CLOUDFLARE_ACCOUNT_ID` environment variable"
89+
);
90+
}
7391

7492
/**
7593
* `CLOUDFLARE_ACCESS_CLIENT_ID` is the Client ID of a Cloudflare Access Service Token.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { afterEach, beforeEach, describe, it, vi } from "vitest";
2+
import { validateAccountId } from "../src/account-id";
3+
import { getCloudflareAccountIdFromEnv } from "../src/env-vars";
4+
5+
describe("validateAccountId", () => {
6+
it("accepts alphanumeric characters, hyphens, and underscores", ({
7+
expect,
8+
}) => {
9+
for (const accountId of [
10+
"a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9",
11+
"ACCOUNT-ID",
12+
"account_id",
13+
"0",
14+
]) {
15+
expect(validateAccountId(accountId, "in the test")).toBe(accountId);
16+
}
17+
});
18+
19+
it("rejects values that cannot be used in an API URL", ({ expect }) => {
20+
for (const accountId of [
21+
"ваш-идентификатор-аккаунта",
22+
"account id",
23+
"\naccount-id",
24+
"account/id",
25+
"account:id",
26+
"",
27+
]) {
28+
expect(() => validateAccountId(accountId, "in the test")).toThrow(
29+
"Account IDs may only contain alphanumeric characters, hyphens, and underscores."
30+
);
31+
}
32+
});
33+
34+
it("names the source of the invalid value", ({ expect }) => {
35+
expect(() =>
36+
validateAccountId("oh no", "set in the `SOME_VARIABLE` variable")
37+
).toThrowErrorMatchingInlineSnapshot(
38+
`[Error: Invalid account ID "oh no" set in the \`SOME_VARIABLE\` variable. Account IDs may only contain alphanumeric characters, hyphens, and underscores.]`
39+
);
40+
});
41+
});
42+
43+
describe("getCloudflareAccountIdFromEnv", () => {
44+
beforeEach(() => {
45+
vi.unstubAllEnvs();
46+
});
47+
afterEach(() => {
48+
vi.unstubAllEnvs();
49+
});
50+
51+
it("returns the value of CLOUDFLARE_ACCOUNT_ID", ({ expect }) => {
52+
vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "some-account-id");
53+
expect(getCloudflareAccountIdFromEnv()).toBe("some-account-id");
54+
});
55+
56+
it("treats an empty value as unset", ({ expect }) => {
57+
vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "");
58+
expect(getCloudflareAccountIdFromEnv()).toBeUndefined();
59+
});
60+
61+
it("throws for a value that cannot be used in an API URL", ({ expect }) => {
62+
vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "ваш-идентификатор-аккаунта");
63+
expect(getCloudflareAccountIdFromEnv).toThrowErrorMatchingInlineSnapshot(
64+
`[Error: Invalid account ID "ваш-идентификатор-аккаунта" set in the \`CLOUDFLARE_ACCOUNT_ID\` environment variable. Account IDs may only contain alphanumeric characters, hyphens, and underscores.]`
65+
);
66+
});
67+
});

packages/wrangler/src/__tests__/pages/project-list.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,20 @@ describe("pages project list", () => {
9595
expect(requests.count).toBe(1);
9696
});
9797

98+
it("should error before making a request when CLOUDFLARE_ACCOUNT_ID contains characters that are invalid in a URL", async ({
99+
expect,
100+
}) => {
101+
vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "ваш-идентификатор-аккаунта");
102+
const requests = mockProjectListRequest(expect, []);
103+
104+
await expect(runWrangler("pages project list")).rejects
105+
.toThrowErrorMatchingInlineSnapshot(`
106+
[Error: Invalid account ID "ваш-идентификатор-аккаунта" set in the \`CLOUDFLARE_ACCOUNT_ID\` environment variable. Account IDs may only contain alphanumeric characters, hyphens, and underscores.]
107+
`);
108+
109+
expect(requests.count).toBe(0);
110+
});
111+
98112
it("should return JSON output when --json flag is provided", async ({
99113
expect,
100114
}) => {

packages/wrangler/src/__tests__/user.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1751,6 +1751,26 @@ describe("User", () => {
17511751
const result = getActiveAccountId({});
17521752
expect(result).toBeUndefined();
17531753
});
1754+
1755+
it("should reject a config.account_id that cannot be used in an API URL", ({
1756+
expect,
1757+
}) => {
1758+
expect(() =>
1759+
getActiveAccountId({ account_id: "ваш-идентификатор-аккаунта" })
1760+
).toThrowErrorMatchingInlineSnapshot(
1761+
`[Error: Invalid account ID "ваш-идентификатор-аккаунта" set as \`account_id\` in your Wrangler configuration file. Account IDs may only contain alphanumeric characters, hyphens, and underscores.]`
1762+
);
1763+
});
1764+
1765+
it("should reject a CLOUDFLARE_ACCOUNT_ID that cannot be used in an API URL", ({
1766+
expect,
1767+
}) => {
1768+
vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "ваш-идентификатор-аккаунта");
1769+
1770+
expect(() => getActiveAccountId({})).toThrowErrorMatchingInlineSnapshot(
1771+
`[Error: Invalid account ID "ваш-идентификатор-аккаунта" set in the \`CLOUDFLARE_ACCOUNT_ID\` environment variable. Account IDs may only contain alphanumeric characters, hyphens, and underscores.]`
1772+
);
1773+
});
17541774
});
17551775

17561776
describe("fetchAllAccounts", () => {
@@ -2148,5 +2168,19 @@ describe("User", () => {
21482168
name: "API Account",
21492169
});
21502170
});
2171+
2172+
it("should reject an invalid env var without making API calls", async ({
2173+
expect,
2174+
}) => {
2175+
vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "ваш-идентификатор-аккаунта");
2176+
2177+
// No getMswSuccessMembershipHandlers — if an API call is made, it will fail
2178+
// with an unhandled-request error rather than the validation error below.
2179+
await expect(
2180+
getOrSelectAccountId({})
2181+
).rejects.toThrowErrorMatchingInlineSnapshot(
2182+
`[Error: Invalid account ID "ваш-идентификатор-аккаунта" set in the \`CLOUDFLARE_ACCOUNT_ID\` environment variable. Account IDs may only contain alphanumeric characters, hyphens, and underscores.]`
2183+
);
2184+
});
21512185
});
21522186
});

0 commit comments

Comments
 (0)