Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -105809,7 +105809,18 @@ async function warmStartInstance(instanceId, { userData, label }) {
const client = ec2Client();
await client.send(new ModifyInstanceAttributeCommand({
InstanceId: instanceId,
UserData: { Value: Buffer.from(userData).toString('base64') },
// ModifyInstanceAttribute's UserData is a BLOB (BlobAttributeValue.Value:
// Uint8Array), NOT the plain string RunInstances takes. The SDK
// base64-encodes the blob's bytes for the EC2 query wire, so hand it the
// RAW user-data bytes. Pre-encoding to a base64 STRING here double-encodes
// it: the wire carries base64(base64(userData)), EC2 decodes that once and
// IMDS then serves the base64 TEXT instead of the script. The warm-restart
// register step reads that via IMDS, its `sed` finds no GH_REPO_URL='...'
// line, and config.sh aborts with "Invalid configuration provided for url"
// — every reuse: stop warm start failed registration this way. Cold
// launches were unaffected: RunInstances (startEc2Instance) correctly
// pre-encodes because its UserData field is a string, not a blob.
UserData: { Value: Buffer.from(userData) },
}));
await client.send(new CreateTagsCommand({
Resources: [instanceId],
Expand Down
13 changes: 12 additions & 1 deletion src/aws.js
Original file line number Diff line number Diff line change
Expand Up @@ -1159,7 +1159,18 @@ async function warmStartInstance(instanceId, { userData, label }) {
const client = ec2Client();
await client.send(new ModifyInstanceAttributeCommand({
InstanceId: instanceId,
UserData: { Value: Buffer.from(userData).toString('base64') },
// ModifyInstanceAttribute's UserData is a BLOB (BlobAttributeValue.Value:
// Uint8Array), NOT the plain string RunInstances takes. The SDK
// base64-encodes the blob's bytes for the EC2 query wire, so hand it the
// RAW user-data bytes. Pre-encoding to a base64 STRING here double-encodes
// it: the wire carries base64(base64(userData)), EC2 decodes that once and
// IMDS then serves the base64 TEXT instead of the script. The warm-restart
// register step reads that via IMDS, its `sed` finds no GH_REPO_URL='...'
// line, and config.sh aborts with "Invalid configuration provided for url"
// — every reuse: stop warm start failed registration this way. Cold
// launches were unaffected: RunInstances (startEc2Instance) correctly
// pre-encodes because its UserData field is a string, not a blob.
UserData: { Value: Buffer.from(userData) },
}));
await client.send(new CreateTagsCommand({
Resources: [instanceId],
Expand Down
66 changes: 66 additions & 0 deletions tests/warmpool-userdata-encoding.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Serialization regression for the warm-pool (reuse: stop) user-data rewrite.
//
// warmStartInstance rewrites a stopped instance's user-data via
// ModifyInstanceAttribute before restarting it. That field is a BLOB
// (BlobAttributeValue.Value: Uint8Array), unlike RunInstances' plain-string
// UserData. The EC2 query protocol base64-encodes a blob's bytes on the wire,
// and EC2 base64-decodes them once before storing what IMDS serves. So the
// value handed to the SDK must be the RAW user-data bytes: a pre-base64'd
// string double-encodes (wire carries base64(base64(userData))), EC2 decodes
// only the outer layer, and IMDS ends up serving the base64 TEXT instead of
// the bootstrap script. The per-boot register step then reads an empty
// GH_REPO_URL and config.sh aborts with "Invalid configuration provided for
// url" — the failure this test guards against.
//
// Unlike warmpool.test.js this file does NOT mock @aws-sdk/client-ec2: it runs
// a real EC2Client through a capturing requestHandler so the assertion pins
// the SDK's actual wire behavior, not a re-implementation of it.
const { EC2Client, ModifyInstanceAttributeCommand } = require('@aws-sdk/client-ec2');

// Serialize a ModifyInstanceAttribute request and return what EC2 would store
// as user-data (i.e. IMDS-served bytes) = the wire UserData.Value blob decoded
// once from base64.
async function imdsWouldServe(value) {
let body = null;
const client = new EC2Client({
region: 'us-east-1',
credentials: { accessKeyId: 'AKIATEST', secretAccessKey: 'secret' },
requestHandler: {
handle(req) {
body = req.body;
// Abort before any network I/O; we only need the serialized body.
return Promise.reject(new Error('__captured__'));
},
},
});
try {
await client.send(new ModifyInstanceAttributeCommand({
InstanceId: 'i-abc', UserData: { Value: value },
}));
} catch (err) {
if (err.message !== '__captured__') throw err;
}
const match = /(?:^|&)UserData\.Value=([^&]*)/.exec(body || '');
if (!match) throw new Error(`UserData.Value not found in serialized body: ${body}`);
const wire = decodeURIComponent(match[1]);
return Buffer.from(wire, 'base64').toString('utf8');
}

describe('ModifyInstanceAttribute user-data blob encoding', () => {
const userData = "#!/bin/bash\nGH_REPO_URL='https://github.com/o/r'\nGH_TOKEN='TOK'\n";

test('raw user-data bytes (the fix) survive the round-trip verbatim', async () => {
// This is exactly what warmStartInstance passes: Buffer.from(userData).
await expect(imdsWouldServe(Buffer.from(userData))).resolves.toBe(userData);
});

test("a pre-base64'd string (the bug) double-encodes and loses the script", async () => {
const served = await imdsWouldServe(Buffer.from(userData).toString('base64'));
// IMDS would serve base64 text, not the script — no GH_REPO_URL line, so
// the warm-restart register step reads an empty URL.
expect(served).not.toBe(userData);
expect(served).not.toMatch(/^GH_REPO_URL=/m);
// It's the singly-encoded base64 of the script (the double-encode victim).
expect(served).toBe(Buffer.from(userData).toString('base64'));
});
});
9 changes: 8 additions & 1 deletion tests/warmpool.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,14 @@ describe('warmStartInstance', () => {
expect(commandsSent()).toEqual(['ModifyInstanceAttribute', 'CreateTags', 'StartInstances']);
const modify = mockSend.mock.calls[0][0];
expect(modify.InstanceId).toBe('i-1');
expect(Buffer.from(modify.UserData.Value, 'base64').toString('utf8')).toContain('#!/bin/bash');
// UserData.Value must be the RAW user-data bytes, not a pre-base64'd
// string: it's a blob the SDK base64-encodes on the wire, so a base64
// string here double-encodes and IMDS serves base64 text (see
// warmStartInstance + the #66 serialization regression test). Assert the
// bytes decode straight back to the script — this fails against the old
// `.toString('base64')` value (a base64 string, not a Buffer/Uint8Array).
expect(Buffer.isBuffer(modify.UserData.Value) || modify.UserData.Value instanceof Uint8Array).toBe(true);
expect(Buffer.from(modify.UserData.Value).toString('utf8')).toBe('#!/bin/bash\ntrue');
const tags = mockSend.mock.calls[1][0].Tags.map((t) => t.Key);
expect(tags).toContain(aws.LABEL_TAG_KEY);
});
Expand Down