Skip to content

Commit 78afe55

Browse files
committed
Check runtime permissions before enabling custom domains.
1 parent 071e552 commit 78afe55

2 files changed

Lines changed: 137 additions & 2 deletions

File tree

‎packages/void/src/cli/platform-management.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -594,7 +594,11 @@ export async function runInstalledPlatformCommand(args: PlatformArgs): Promise<v
594594
r2SecretAccessKey: process.env.VOID_PLATFORM_R2_SECRET_ACCESS_KEY || '',
595595
...replacedLogin,
596596
});
597-
const runtimeTokenToPreflight = resolvePlatformRuntimeTokenToPreflight(plan, secrets);
597+
const runtimeTokenToPreflight = resolvePlatformRuntimeTokenToPreflight(plan, secrets, {
598+
enablingCustomDomains:
599+
plan.deployment.capabilities.customDomains &&
600+
!manifest.deployment.capabilities.customDomains,
601+
});
598602
// Shared email setup runs after traffic pauses and uses the runtime token,
599603
// also when this run keeps the saved token. Check its email access first.
600604
const emailZoneId =
@@ -967,11 +971,22 @@ export function resolvePlatformRuntimeTokenToPreflight(
967971
ReturnType<typeof createPlatformInstallationSecrets>,
968972
'cloudflareApiToken' | 'workerSecretUpdates'
969973
>,
974+
options: { enablingCustomDomains?: boolean } = {},
970975
): string {
971976
const explicitUpdate = secrets.workerSecretUpdates?.CF_API_TOKEN;
972977
if (explicitUpdate) {
973978
return explicitUpdate;
974979
}
980+
// Enabling custom hostnames adds a runtime permission even when the Workers
981+
// retain their token. Check it before the installer can pause traffic.
982+
if (options.enablingCustomDomains) {
983+
if (!secrets.cloudflareApiToken) {
984+
throw new Error(
985+
'platform: Before enabling project custom domains, set VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN to the platform runtime token with Zone → SSL and Certificates → Edit on the application zone so Void can check its access.',
986+
);
987+
}
988+
return secrets.cloudflareApiToken;
989+
}
975990
return plan.actions.some(
976991
(action) =>
977992
action.kind === 'worker' &&

‎packages/void/test/unit/platform-management-upgrade.test.ts‎

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ import {
259259
writePendingInstallationSecrets,
260260
} from '../../src/cli/platform-registry.ts';
261261

262-
const upgrade = (overrides: { plan?: boolean; yes?: boolean } = {}) =>
262+
const upgrade = (overrides: { plan?: boolean; yes?: boolean; customDomains?: true } = {}) =>
263263
runInstalledPlatformCommand({
264264
subcommand: 'upgrade',
265265
selector: state.manifest!.id,
@@ -921,6 +921,126 @@ describe('installed platform upgrade', () => {
921921
});
922922
});
923923

924+
describe('enabling project custom domains', () => {
925+
const customHostnamePath = '/zones/zone/custom_hostnames?per_page=1';
926+
927+
beforeEach(() => {
928+
state.manifest = {
929+
...state.manifest!,
930+
deployment: createPlatformDeploymentManifest({
931+
...state.manifest!.deployment,
932+
application: { baseDomain: 'example.com', zoneName: 'example.com', dedicatedZone: true },
933+
}),
934+
};
935+
const planner = vi.mocked(createPlatformInstallPlan).getMockImplementation()!;
936+
vi.mocked(createPlatformInstallPlan).mockImplementationOnce(async (...args) => {
937+
const plan = await planner(...args);
938+
return {
939+
...plan,
940+
deployment: createPlatformDeploymentManifest({
941+
...plan.deployment,
942+
capabilities: {
943+
...plan.deployment.capabilities,
944+
...(args[1].customDomains ? { customDomains: true } : {}),
945+
},
946+
}),
947+
zone: { id: 'zone', name: 'example.com', nameServers: [], status: 'active' },
948+
};
949+
});
950+
state.createSecrets.mockReturnValue({ cloudflareApiToken: 'saved-runtime-token' });
951+
state.request.mockImplementation(async (path: string) => {
952+
state.events.push(`request:${path}`);
953+
return [];
954+
});
955+
});
956+
957+
it.each(['upgrade', 'repair'] as const)(
958+
'checks the retained token before %s can pause traffic',
959+
async (subcommand) => {
960+
await runInstalledPlatformCommand({
961+
subcommand,
962+
selector: state.manifest!.id,
963+
runtime: '',
964+
customDomains: true,
965+
plan: false,
966+
yes: true,
967+
});
968+
expect(state.clientTokens).toContain('saved-runtime-token');
969+
const probe = state.events.indexOf(`request:${customHostnamePath}`);
970+
expect(probe).toBeGreaterThan(-1);
971+
expect(probe).toBeLessThan(state.events.indexOf('installer-start'));
972+
expect(state.createSecrets).toHaveBeenCalledWith(
973+
expect.objectContaining({ cloudflareApiToken: '' }),
974+
);
975+
},
976+
);
977+
978+
it.each([401, 403, 429, 503])(
979+
'stops before deployment when the token check returns %s',
980+
async (status) => {
981+
state.request.mockImplementation(async (path: string) => {
982+
if (path === customHostnamePath) {
983+
throw new PlatformCloudflareApiError(
984+
'upstream echoed saved-runtime-token',
985+
status,
986+
path,
987+
);
988+
}
989+
return [];
990+
});
991+
const failure = await upgrade({ customDomains: true }).catch((error: Error) => error);
992+
expect(failure).toBeInstanceOf(Error);
993+
expect(String(failure)).toContain(`HTTP ${status}`);
994+
expect(String(failure)).not.toContain('saved-runtime-token');
995+
if (status === 403) {
996+
expect(String(failure)).toContain('SSL and Certificates → Edit');
997+
}
998+
expect(installPlatform).not.toHaveBeenCalled();
999+
expect(state.events.filter((event) => event.startsWith('put:'))).toEqual([]);
1000+
},
1001+
);
1002+
1003+
it('requires a runtime token when enabling custom domains without saved credentials', async () => {
1004+
state.createSecrets.mockReturnValue({});
1005+
await expect(upgrade({ customDomains: true })).rejects.toThrow(
1006+
'VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN',
1007+
);
1008+
expect(installPlatform).not.toHaveBeenCalled();
1009+
});
1010+
1011+
it('checks an explicit replacement instead of the retained token', async () => {
1012+
state.createSecrets.mockReturnValue({
1013+
cloudflareApiToken: 'replacement-token',
1014+
workerSecretUpdates: { CF_API_TOKEN: 'replacement-token' },
1015+
});
1016+
await upgrade({ customDomains: true });
1017+
expect(state.clientTokens).toContain('replacement-token');
1018+
expect(state.clientTokens).not.toContain('saved-runtime-token');
1019+
expect(state.events).toContain(`request:${customHostnamePath}`);
1020+
});
1021+
1022+
it('does not check retained tokens when custom domains are already enabled', async () => {
1023+
state.manifest = {
1024+
...state.manifest!,
1025+
deployment: createPlatformDeploymentManifest({
1026+
...state.manifest!.deployment,
1027+
capabilities: { ...state.manifest!.deployment.capabilities, customDomains: true },
1028+
}),
1029+
};
1030+
await upgrade();
1031+
expect(state.clientTokens).not.toContain('saved-runtime-token');
1032+
expect(state.events).not.toContain(`request:${customHostnamePath}`);
1033+
});
1034+
1035+
it('can plan enabling custom domains without a runtime token', async () => {
1036+
state.createSecrets.mockReturnValue({});
1037+
await upgrade({ customDomains: true, plan: true });
1038+
expect(state.clientTokens).not.toContain('saved-runtime-token');
1039+
expect(installPlatform).not.toHaveBeenCalled();
1040+
expect(state.events).not.toContain(`request:${customHostnamePath}`);
1041+
});
1042+
});
1043+
9241044
describe('runtime token email access', () => {
9251045
const emailPaths = [
9261046
'/zones/mail-zone',

0 commit comments

Comments
 (0)