Skip to content

Commit 2d286a4

Browse files
committed
fix: harden hosted app lifecycle and preview access
1 parent d1e63bd commit 2d286a4

16 files changed

Lines changed: 426 additions & 63 deletions

docs/lambda-microvm/README.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -270,12 +270,14 @@ configuration:
270270
|---|---|---|
271271
| `LAMBDA_MICROVM_APP_IMAGE_ARN` || Dedicated `lambda-microvm-app-host` image ARN. |
272272
| `LAMBDA_MICROVM_APP_IMAGE_VERSION` || Required pinned image version. |
273-
| `LAMBDA_MICROVM_APP_CONTROL_PORT` | `8080` | Root-owned runner control/checkpoint port. |
274-
| `LAMBDA_MICROVM_APP_PREVIEW_PORT` | `3000` | Untrusted resident app port; must differ from the control port. |
275273
| `LAMBDA_MICROVM_APP_MAX_DURATION_SECONDS` | `28800` | App-VM hard lifetime. The control plane relaunches an immutable revision after expiry. |
276274
| `LAMBDA_MICROVM_APP_IDLE_SECONDS` | `300` | Seconds idle before AWS suspends the VM. |
277275
| `LAMBDA_MICROVM_APP_SUSPEND_SECONDS` | `900` | Seconds suspended before AWS terminates the VM. Suspended VMs still consume quota. |
278-
| `LAMBDA_MICROVM_APP_START_TIMEOUT_MS` | `30000` | Resident process readiness budget after restore. |
276+
277+
The pinned app-host image contract fixes the root-owned control/checkpoint
278+
listener at port 8080, the resident app at port 3000, and resident readiness at
279+
30 seconds. `RunMicrovm` cannot override the image environment; changing this
280+
contract requires publishing a matching image and control-plane revision.
279281

280282
Generate the two keys independently:
281283

@@ -287,10 +289,13 @@ openssl rand -base64 32 # CODEAPI_HOSTED_APP_PREVIEW_SIGNING_KEY
287289
The preview proxy strips CodeAPI authorization, cookies, forwarded identity,
288290
caller-provided AWS headers, app `Set-Cookie`, cross-origin policy, and external
289291
redirects. It supports streamed HTTP/SSE and same-origin redirects. WebSockets
290-
are not part of this first resident adapter. A gateway-owned CSP keeps browser
291-
network access same-origin and disables workers/service workers, so one app
292-
revision cannot leave a persistent worker controlling a later revision. The
293-
request `env` map is persisted
292+
are not part of this first resident adapter. A gateway-owned CSP constrains
293+
fetches and subresources to the app origin and disables workers/service workers,
294+
so one app revision cannot leave a persistent worker controlling a later
295+
revision. Top-level app JavaScript can still navigate the owner's browser to an
296+
external origin; treat this experimental viewer as owner-trusted. Before broad
297+
untrusted enablement, serve app content from a separate origin inside a sandboxed
298+
gateway wrapper. The request `env` map is persisted
294299
with the immutable launch spec in the registry; it is configuration, not a
295300
secret store. Add a dedicated secret-reference flow before passing application
296301
secrets to hosted code.

service/src/config.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -409,14 +409,11 @@ export const env = {
409409
HOSTED_APPS_ENABLED: process.env.CODEAPI_HOSTED_APPS_ENABLED === 'true',
410410
HOSTED_APP_IMAGE_ARN: process.env.LAMBDA_MICROVM_APP_IMAGE_ARN ?? '',
411411
HOSTED_APP_IMAGE_VERSION: process.env.LAMBDA_MICROVM_APP_IMAGE_VERSION || undefined,
412-
HOSTED_APP_CONTROL_PORT: configuredNumber(
413-
process.env.LAMBDA_MICROVM_APP_CONTROL_PORT,
414-
8080,
415-
),
416-
HOSTED_APP_PREVIEW_PORT: configuredNumber(
417-
process.env.LAMBDA_MICROVM_APP_PREVIEW_PORT,
418-
3000,
419-
),
412+
/* These values are part of the pinned app-host image contract. RunMicrovm
413+
* cannot inject environment variables into the image, so exposing overrides
414+
* here would only make the control plane call ports the runner never opened. */
415+
HOSTED_APP_CONTROL_PORT: 8080 as number,
416+
HOSTED_APP_PREVIEW_PORT: 3000 as number,
420417
HOSTED_APP_MAX_DURATION_SECONDS: configuredNumber(
421418
process.env.LAMBDA_MICROVM_APP_MAX_DURATION_SECONDS,
422419
28_800,
@@ -429,10 +426,7 @@ export const env = {
429426
process.env.LAMBDA_MICROVM_APP_SUSPEND_SECONDS,
430427
900,
431428
),
432-
HOSTED_APP_START_TIMEOUT_MS: configuredNumber(
433-
process.env.LAMBDA_MICROVM_APP_START_TIMEOUT_MS,
434-
30_000,
435-
),
429+
HOSTED_APP_START_TIMEOUT_MS: 30_000 as number,
436430
HOSTED_APP_CREDENTIAL_KEY: process.env.CODEAPI_HOSTED_APP_CREDENTIAL_KEY ?? '',
437431
HOSTED_APP_PREVIEW_ORIGIN: process.env.CODEAPI_HOSTED_APP_PREVIEW_ORIGIN ?? '',
438432
HOSTED_APP_PREVIEW_SIGNING_KEY:

service/src/hosted-app/control-plane.test.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ class FakeRuntime {
8282
healthChecks: string[] = [];
8383
terminations: string[] = [];
8484
previewMints = 0;
85+
previewExpiresAt = 1_900_000_000_000;
86+
startedAtMs?: number;
8587
terminateSucceeds = true;
8688
launchError?: Error;
8789
healthError?: Error;
@@ -94,6 +96,7 @@ class FakeRuntime {
9496
microvmId: 'vm-app-1',
9597
endpoint: 'https://vm-app-1.test',
9698
state: 'RUNNING',
99+
startedAtMs: this.startedAtMs,
97100
imageArn: runtimeConfig.imageArn,
98101
imageVersion: runtimeConfig.imageVersion,
99102
},
@@ -120,7 +123,7 @@ class FakeRuntime {
120123
return {
121124
headerName: 'X-aws-proxy-auth',
122125
token: `secret-token-${this.previewMints}`,
123-
expiresAtMs: 1_900_000_000_000,
126+
expiresAtMs: this.previewExpiresAt,
124127
};
125128
}
126129
async terminate(microvmId: string): Promise<boolean> {
@@ -215,6 +218,18 @@ describe('HostedAppControlPlane', () => {
215218
expect(hostedAppPublicStatus(expired, 100).state).toBe('stopped');
216219
});
217220

221+
test('does not expose provider details persisted in an internal failure record', () => {
222+
const failed = {
223+
...pendingRecord(),
224+
state: 'TERMINATED' as const,
225+
last_error: 'AccessDenied for arn:aws:iam::123456789012:role/private',
226+
};
227+
const status = hostedAppPublicStatus(failed);
228+
expect(status.state).toBe('failed');
229+
expect(status.error).toBe('Hosted app operation failed');
230+
expect(JSON.stringify(status)).not.toContain('123456789012');
231+
});
232+
218233
test('checkpoints, launches, restores, starts, and persists only a sealed preview credential', async () => {
219234
const f = fixture();
220235

@@ -230,6 +245,19 @@ describe('HostedAppControlPlane', () => {
230245
expect(openHostedAppCredential('happ_123', sealed, f.credentialKey).token).toBe('secret-token-1');
231246
});
232247

248+
test('derives the advertised lease deadline from the provider start time', async () => {
249+
const runtime = new FakeRuntime();
250+
runtime.startedAtMs = 1_800_000_000_500;
251+
const f = fixture({ runtime });
252+
253+
await f.control.start(input);
254+
255+
expect(f.registry.record?.launched_at).toBe(runtime.startedAtMs);
256+
expect(f.registry.record?.hard_deadline_at).toBe(
257+
runtime.startedAtMs + runtimeConfig.maximumDurationSeconds * 1_000 - 60_000,
258+
);
259+
});
260+
233261
test('replays an exact pending launch intent without taking a different checkpoint', async () => {
234262
const registry = new MemoryRegistry();
235263
registry.record = pendingRecord();
@@ -365,6 +393,61 @@ describe('HostedAppControlPlane', () => {
365393
});
366394
});
367395

396+
test('stop replays an ambiguous pending launch before terminating it', async () => {
397+
const registry = new MemoryRegistry();
398+
registry.record = pendingRecord();
399+
const f = fixture({ registry });
400+
401+
const status = await f.control.stop(input.hostedAppRuntimeId, input, input.signal);
402+
403+
expect(status.state).toBe('stopped');
404+
expect(f.runtime.launches).toEqual([pendingRecord().launch_client_token as string]);
405+
expect(f.runtime.terminations).toEqual(['vm-app-1']);
406+
expect(registry.writes.map(record => record.state)).toEqual([
407+
'PENDING',
408+
'TERMINATING',
409+
'TERMINATED',
410+
]);
411+
expect(registry.record).toMatchObject({
412+
state: 'TERMINATED',
413+
microvm_id: undefined,
414+
endpoint: undefined,
415+
});
416+
});
417+
418+
test('stop preserves an ambiguous pending intent when recovery fails', async () => {
419+
const registry = new MemoryRegistry();
420+
registry.record = pendingRecord();
421+
const runtime = new FakeRuntime();
422+
runtime.launchError = new HostedAppMicrovmError(
423+
'hosted_app_launch_failed',
424+
'connection reset after provider accepted the request',
425+
true,
426+
);
427+
const f = fixture({ registry, runtime });
428+
429+
const error = await f.control.stop(input.hostedAppRuntimeId, input, input.signal)
430+
.catch(value => value);
431+
432+
expect(error.code).toBe('hosted_app_launch_failed');
433+
expect(registry.writes).toEqual([]);
434+
expect(registry.record).toEqual(pendingRecord());
435+
});
436+
437+
test('stop does not overwrite a pending intent that current config cannot replay', async () => {
438+
const registry = new MemoryRegistry();
439+
registry.record = { ...pendingRecord(), launch_fingerprint: 'different-image' };
440+
const f = fixture({ registry });
441+
442+
const error = await f.control.stop(input.hostedAppRuntimeId, input, input.signal)
443+
.catch(value => value);
444+
445+
expect(error.code).toBe('hosted_app_stop_pending');
446+
expect(error.transient).toBe(true);
447+
expect(f.runtime.launches).toEqual([]);
448+
expect(registry.writes).toEqual([]);
449+
});
450+
368451
test('a failed cleanup never promotes a partial launch to running', async () => {
369452
const registry = new MemoryRegistry();
370453
registry.record = {
@@ -408,4 +491,34 @@ describe('HostedAppControlPlane', () => {
408491
expect(ambiguous.registry.record?.state).toBe('PENDING');
409492
expect(ambiguous.registry.record?.microvm_id).toBeUndefined();
410493
});
494+
495+
test('does not refresh a preview after its advertised hard deadline', async () => {
496+
const registry = new MemoryRegistry();
497+
registry.record = {
498+
...pendingRecord(),
499+
state: 'RUNNING',
500+
microvm_id: 'vm-existing',
501+
endpoint: 'https://vm-existing.test',
502+
hard_deadline_at: 1_800_000_000_000,
503+
};
504+
const f = fixture({ registry });
505+
506+
const error = await f.control.refreshPreview(input.hostedAppRuntimeId, input, input.signal)
507+
.catch(value => value);
508+
509+
expect(error.code).toBe('hosted_app_not_running');
510+
expect(f.runtime.previewMints).toBe(0);
511+
});
512+
513+
test('rejects an already-expired credential instead of publishing it', async () => {
514+
const runtime = new FakeRuntime();
515+
runtime.previewExpiresAt = 1_800_000_000_000;
516+
const f = fixture({ runtime });
517+
518+
const error = await f.control.start(input).catch(value => value);
519+
520+
expect(error.code).toBe('hosted_app_preview_unavailable');
521+
expect(runtime.terminations).toEqual(['vm-app-1']);
522+
expect(f.registry.record).toMatchObject({ state: 'TERMINATED' });
523+
});
411524
});

0 commit comments

Comments
 (0)