Include preview token in Access probes and login (#15159) · cloudflare/workers-sdk@7bb6eae · GitHub
Skip to content

Commit 7bb6eae

Browse files
veggiedefenderefalcaocjol
authored
Include preview token in Access probes and login (#15159)
Co-authored-by: Eric Falcao <efalcao@cloudflare.com> Co-authored-by: Christopher Little-Savage <clittle-savage@cloudflare.com>
1 parent f1179eb commit 7bb6eae

10 files changed

Lines changed: 270 additions & 17 deletions

File tree

Lines changed: 7 additions & 0 deletions

‎packages/remote-bindings/src/startDevWorker/RemoteRuntimeController.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ export class RemoteRuntimeController {
228228

229229
const accessHeaders = await getAccessHeaders(token.host, {
230230
logger,
231+
previewToken: token.value,
231232
});
232233

233234
const proxyData: ProxyData = {

‎packages/workers-auth/src/access.ts‎

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ const headersCache: Record<string, Record<string, string>> = {};
1313

1414
const usesAccessCache = new Map<string, boolean>();
1515

16+
function buildProbeUrl(domain: string, previewToken?: string): string {
17+
const url = new URL(`https://${domain}`);
18+
if (previewToken) {
19+
url.searchParams.set("cf_workers_preview_token", previewToken);
20+
}
21+
return url.toString();
22+
}
23+
1624
/**
1725
* Clear internal caches. Exported for use in tests only.
1826
*/
@@ -29,20 +37,29 @@ export function clearAccessCaches(): void {
2937
* A 302 to `cloudflareaccess.com` is the canonical signal. Service-auth-only
3038
* Access applications return a hard 403 instead and are therefore not detected
3139
* here — see {@link getAccessHeaders} for how this is handled.
40+
*
41+
* When `previewToken` is supplied it is added as the `cf_workers_preview_token`
42+
* query parameter so the probe activates an unpublished worker's edge-preview
43+
* route (otherwise the anonymous probe 404s and Access is never triggered). The
44+
* cache is keyed on whether a token was supplied so token-authenticated and
45+
* anonymous probes of the same host don't poison each other.
3246
*/
3347
export async function domainUsesAccess(
3448
domain: string,
35-
logger: OAuthFlowLogger
49+
logger: OAuthFlowLogger,
50+
previewToken?: string
3651
): Promise<boolean> {
3752
logger.debug("Checking if domain has Access enabled:", domain);
3853

39-
if (usesAccessCache.has(domain)) {
54+
const cacheKey = JSON.stringify([domain, Boolean(previewToken)]);
55+
56+
if (usesAccessCache.has(cacheKey)) {
4057
logger.debug(
4158
"Using cached Access switch for:",
4259
domain,
43-
usesAccessCache.get(domain)
60+
usesAccessCache.get(cacheKey)
4461
);
45-
return usesAccessCache.get(domain) ?? false;
62+
return usesAccessCache.get(cacheKey) ?? false;
4663
}
4764
logger.debug("Access switch not cached for:", domain);
4865
try {
@@ -51,7 +68,7 @@ export async function domainUsesAccess(
5168
controller.abort();
5269
}, 1000);
5370

54-
const output = await fetch(`https://${domain}`, {
71+
const output = await fetch(buildProbeUrl(domain, previewToken), {
5572
redirect: "manual",
5673
signal: controller.signal,
5774
});
@@ -60,12 +77,17 @@ export async function domainUsesAccess(
6077
output.status === 302 &&
6178
output.headers.get("location")?.includes("cloudflareaccess.com")
6279
);
63-
logger.debug("Caching access switch for:", domain);
64-
65-
usesAccessCache.set(domain, usesAccess);
80+
// A token-bearing probe can miss Access if its preview route isn't live
81+
// yet, and refreshed tokens reuse the same host. Only cache positive
82+
// results for those probes so a later token is probed again.
83+
if (usesAccess || !previewToken) {
84+
logger.debug("Caching access switch for:", domain);
85+
usesAccessCache.set(cacheKey, usesAccess);
86+
}
6687
return usesAccess;
6788
} catch {
68-
usesAccessCache.set(domain, false);
89+
// Don't cache errors, or else transient failures will continue to fail forever
90+
logger.debug("Access probe failed for:", domain);
6991
return false;
7092
}
7193
}
@@ -75,7 +97,10 @@ export async function domainUsesAccess(
7597
*
7698
* @param domain The hostname of the Access-protected domain (e.g. `"example.com"`).
7799
* @param options logger + an `isNonInteractiveOrCI` predicate used to
78-
* produce an actionable error in CI; both default to no-op / `false`.
100+
* produce an actionable error in CI (both default to no-op / `false`), plus an
101+
* optional `previewToken` that is added as the `cf_workers_preview_token`
102+
* query parameter when probing for Access and when invoking `cloudflared
103+
* access login`, so detection works on unpublished `<name>.workers.dev` hosts.
79104
* @returns
80105
* - Service token headers (`CF-Access-Client-Id` + `CF-Access-Client-Secret`) if env vars are set
81106
* - A `Cookie: CF_Authorization=...` header if obtained via `cloudflared` (interactive only)
@@ -92,6 +117,12 @@ export async function getAccessHeaders(
92117
isNonInteractiveOrCI?: () => boolean;
93118
/** Aborts a pending `cloudflared` authorization and kills its process. */
94119
signal?: AbortSignal;
120+
/**
121+
* Workers preview token forwarded to the Access probe and to
122+
* `cloudflared access login` so detection works on unpublished
123+
* `<name>.workers.dev` hosts.
124+
*/
125+
previewToken?: string;
95126
}
96127
): Promise<Record<string, string>> {
97128
const logger = options.logger;
@@ -128,7 +159,7 @@ export async function getAccessHeaders(
128159
);
129160
}
130161

131-
if (!(await domainUsesAccess(domain, logger))) {
162+
if (!(await domainUsesAccess(domain, logger, options.previewToken))) {
132163
return {};
133164
}
134165
logger.debug("Getting Access headers for domain:", domain);
@@ -153,7 +184,22 @@ export async function getAccessHeaders(
153184

154185
// 3. Interactive: fall back to cloudflared
155186
logger.debug("Spawning cloudflared to get Access token for domain:");
156-
const output = await spawnCloudflaredAccessLogin(domain, options.signal);
187+
188+
// Knowledge of the preview token allows you to route to the unpublished
189+
// worker (which may have production bindings and production cache) so in some
190+
// sense it's a secret.
191+
//
192+
// During the Access login flow, the preview token may be visible to other
193+
// processes on the machine. It will also appear in browser history and may be
194+
// sent to third parties like the identity provider. Are these leaks safe?
195+
//
196+
// I argue yes! The preview worker is protected by Access, or we wouldn't be
197+
// trying to log into it. So the preview token is necessary but not sufficient
198+
// to reach it.
199+
const output = await spawnCloudflaredAccessLogin(
200+
buildProbeUrl(domain, options.previewToken),
201+
options.signal
202+
);
157203
if (output.error) {
158204
throw new UserError(
159205
"To use Wrangler with Cloudflare Access, please install `cloudflared` from https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation",
@@ -186,11 +232,13 @@ export async function getAccessHeaders(
186232
* surface an installation hint.
187233
*/
188234
function spawnCloudflaredAccessLogin(
189-
domain: string,
235+
loginTarget: string,
190236
signal?: AbortSignal
191237
): Promise<{ error?: Error; stdout: string }> {
192238
return new Promise((resolve, reject) => {
193-
const child = spawn("cloudflared", ["access", "login", domain], { signal });
239+
const child = spawn("cloudflared", ["access", "login", loginTarget], {
240+
signal,
241+
});
194242
const stdoutChunks: Buffer[] = [];
195243
child.stdout.on("data", (chunk: Buffer) => stdoutChunks.push(chunk));
196244
child.stderr.resume();

‎packages/workers-auth/tests/access.test.ts‎

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { spawn } from "node:child_process";
22
import { EventEmitter } from "node:events";
3+
import { http, HttpResponse } from "msw";
34
import { setupServer } from "msw/node";
45
import {
56
afterAll,
@@ -116,6 +117,77 @@ describe("access", () => {
116117
await domainUsesAccess("access-service-auth-only.com", silentLogger)
117118
).toBeFalsy();
118119
});
120+
121+
it("forwards the preview token and caches token-authenticated probes separately", async ({
122+
expect,
123+
}) => {
124+
// Simulates an unpublished workers.dev host: the edge only routes the
125+
// request (letting the wildcard Access app respond with its 302) once
126+
// the preview token activates the edge-preview route. An anonymous
127+
// probe 404s and must not poison the token-authenticated result.
128+
msw.use(
129+
http.get("https://unpublished.workers.dev/", ({ request }) => {
130+
const token = new URL(request.url).searchParams.get(
131+
"cf_workers_preview_token"
132+
);
133+
if (!token) {
134+
return HttpResponse.json(null, { status: 404 });
135+
}
136+
return HttpResponse.json(null, {
137+
status: 302,
138+
headers: { location: "unpublished.cloudflareaccess.com" },
139+
});
140+
})
141+
);
142+
143+
expect(
144+
await domainUsesAccess("unpublished.workers.dev", silentLogger)
145+
).toBeFalsy();
146+
expect(
147+
await domainUsesAccess(
148+
"unpublished.workers.dev",
149+
silentLogger,
150+
"preview-token"
151+
)
152+
).toBeTruthy();
153+
});
154+
155+
it("re-probes with a later preview token after a token-bearing probe misses Access", async ({
156+
expect,
157+
}) => {
158+
// The first token's preview route isn't live yet, so its probe 404s. A
159+
// refreshed token for the same host must be probed rather than reusing
160+
// that negative result.
161+
msw.use(
162+
http.get("https://unpublished.workers.dev/", ({ request }) => {
163+
const token = new URL(request.url).searchParams.get(
164+
"cf_workers_preview_token"
165+
);
166+
if (token !== "live-token") {
167+
return HttpResponse.json(null, { status: 404 });
168+
}
169+
return HttpResponse.json(null, {
170+
status: 302,
171+
headers: { location: "unpublished.cloudflareaccess.com" },
172+
});
173+
})
174+
);
175+
176+
expect(
177+
await domainUsesAccess(
178+
"unpublished.workers.dev",
179+
silentLogger,
180+
"stale-token"
181+
)
182+
).toBeFalsy();
183+
expect(
184+
await domainUsesAccess(
185+
"unpublished.workers.dev",
186+
silentLogger,
187+
"live-token"
188+
)
189+
).toBeTruthy();
190+
});
119191
});
120192

121193
describe("getAccessHeaders", () => {
@@ -365,7 +437,37 @@ See https://developers.cloudflare.com/cloudflare-one/access-controls/service-cre
365437
});
366438
expect(spawn).toHaveBeenCalledWith(
367439
"cloudflared",
368-
["access", "login", "access-protected.com"],
440+
["access", "login", "https://access-protected.com/"],
441+
{ signal: undefined }
442+
);
443+
});
444+
445+
it("should forward the preview token to cloudflared as a query parameter", async ({
446+
expect,
447+
}) => {
448+
const fake = createFakeProcess();
449+
vi.mocked(spawn).mockReturnValueOnce(fake.child);
450+
451+
const pendingHeaders = getAccessHeaders("access-protected.com", {
452+
logger: silentLogger,
453+
isNonInteractiveOrCI: () => false,
454+
previewToken: "preview-token",
455+
});
456+
await vi.waitFor(() => {
457+
expect(spawn).toHaveBeenCalledOnce();
458+
});
459+
fake.complete("fetched your token:\n\ntest-access-token\n");
460+
461+
await expect(pendingHeaders).resolves.toEqual({
462+
Cookie: "CF_Authorization=test-access-token",
463+
});
464+
expect(spawn).toHaveBeenCalledWith(
465+
"cloudflared",
466+
[
467+
"access",
468+
"login",
469+
"https://access-protected.com/?cf_workers_preview_token=preview-token",
470+
],
369471
{ signal: undefined }
370472
);
371473
});

‎packages/wrangler/e2e/README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@ pnpm test:e2e:wrangler
6666
> You can find your subdomain in the Cloudflare dashboard under **Workers & Pages**.
6767
> It defaults to `devprod-testing7928.workers.dev` (the CI account).
6868
69+
The Cloudflare Access tests need a self-hosted Access application covering a
70+
wildcard of Worker names on that subdomain (for example
71+
`my-access-test-*.<your-subdomain>.workers.dev`) with an interactive-login
72+
policy. Set `E2E_ACCESS_WORKER_PREFIX=my-access-test-` to run them; otherwise
73+
they are skipped when `E2E_ACCOUNT_WORKERS_DEV_DOMAIN` is overridden. The CI
74+
account uses `wrangler-wildcard-test-`.
75+
6976
### Focusing on a single e2e test file
7077

7178
If you want to run a subset of tests (e.g. just one) while retaining the turborepo cache for the builds of the dependencies, you can provide the list of test files via the `WRANGLER_E2E_TEST_FILE` environment variable.

‎packages/wrangler/e2e/helpers/account-id.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,19 @@ export const CLOUDFLARE_ACCOUNT_ID = process.env
1010
export const E2E_ACCOUNT_WORKERS_DEV_DOMAIN =
1111
process.env.E2E_ACCOUNT_WORKERS_DEV_DOMAIN ??
1212
"devprod-testing7928.workers.dev";
13+
14+
/**
15+
* Worker-name prefix covered by a wildcard Cloudflare Access application on
16+
* the e2e account's workers.dev subdomain. The CI account has an Access app
17+
* for `wrangler-wildcard-test-*.devprod-testing7928.workers.dev`, which
18+
* requires an interactive login.
19+
*
20+
* When running against another account (`E2E_ACCOUNT_WORKERS_DEV_DOMAIN`),
21+
* set `E2E_ACCESS_WORKER_PREFIX` to a matching prefix on that account, or the
22+
* Access tests are skipped.
23+
*/
24+
export const E2E_ACCESS_WORKER_PREFIX =
25+
process.env.E2E_ACCESS_WORKER_PREFIX ??
26+
(process.env.E2E_ACCOUNT_WORKERS_DEV_DOMAIN === undefined
27+
? "wrangler-wildcard-test-"
28+
: undefined);
Lines changed: 68 additions & 0 deletions

0 commit comments

Comments
 (0)