-
Notifications
You must be signed in to change notification settings - Fork 2
fix(tokowaka-client): real browser UA for verifyRouting human probe #1749
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c4e7c40
10da7f7
722c643
77c6dd1
18f498e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,7 @@ import { | |
| DescribeFunctionCommand, | ||
| PublishFunctionCommand, | ||
| UpdateDistributionCommand, | ||
| TagResourceCommand, | ||
| } from '@aws-sdk/client-cloudfront'; | ||
| import { | ||
| IAMClient, | ||
|
|
@@ -103,6 +104,49 @@ const delay = (ms) => new Promise((resolve) => { | |
| setTimeout(resolve, ms); | ||
| }); | ||
|
|
||
| // AWS RoleSessionName allows [\w+=,.@-] and 2-64 chars. Use the operator's email so the customer's | ||
| // CloudTrail attributes every mutation to the person who ran it; fall back to the generic name. | ||
| function sessionNameFor(operator) { | ||
| const cleaned = String(operator || '').replace(/[^\w+=,.@-]/g, '').slice(0, 64); | ||
| return cleaned.length >= 2 ? cleaned : SESSION_NAME; | ||
| } | ||
|
|
||
| // Ownership marks on resources we create: a fixed owner flag for reuse + the operator as a | ||
| // breadcrumb. Tagged on the taggable resources (Lambda, its exec role, the CloudFront function); | ||
| // cache policy + origin (not taggable) use their name + Comment / EO-header markers instead. | ||
| const OWNER_TAG_KEY = 'ManagedBy'; | ||
| const OWNER_TAG_VALUE = 'adobe-llmo'; | ||
| const CREATED_BY_TAG_KEY = 'createdBy'; | ||
| const tagCreatedBy = (operator) => String(operator || 'unknown') | ||
| .replace(/[^\w.@+=:/ -]/g, '') | ||
| .slice(0, 256) || 'unknown'; | ||
| // IAM CreateRole + CloudFront TagResource take [{Key,Value}]; Lambda takes an object. | ||
| const ownerTagPairs = (operator) => [ | ||
| { Key: OWNER_TAG_KEY, Value: OWNER_TAG_VALUE }, | ||
| { Key: CREATED_BY_TAG_KEY, Value: tagCreatedBy(operator) }, | ||
| ]; | ||
| const ownerTagsObject = (operator) => Object.fromEntries( | ||
| ownerTagPairs(operator).map((t) => [t.Key, t.Value]), | ||
| ); | ||
|
|
||
| // Substring stamped in the Comment of the CloudFront resources we create (function + cloned cache | ||
| // policy). Non-taggable/Comment-only resources use this as their ownership signal. | ||
| const OWNER_COMMENT_MARKER = 'managed by LLM Optimizer'; | ||
| // True when an IAM Role's tags (from GetRole) carry our fixed owner marker. | ||
| const roleTagsOwned = (tags = []) => tags.some( | ||
| (t) => t.Key === OWNER_TAG_KEY && t.Value === OWNER_TAG_VALUE, | ||
| ); | ||
| // Guards reuse: never silently reuse or modify a same-named resource we did not create. Throws an | ||
| // actionable error the deploy step surfaces so the customer can finish setup manually. | ||
| function assertOwnedOrThrow(resourceLabel, name, isOurs) { | ||
| if (!isOurs) { | ||
| throw new Error( | ||
| `A CloudFront ${resourceLabel} named "${name}" already exists but was not created by ` | ||
| + 'LLM Optimizer. Remove or rename it, or complete the Edge Optimize setup manually.', | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Assume the customer's cross-account connector role and return short-lived credentials. | ||
| * | ||
|
|
@@ -116,13 +160,16 @@ const delay = (ms) => new Promise((resolve) => { | |
| * @param {string} params.externalId - external ID baked into the connector role trust policy. | ||
| * @param {string} [params.roleName] - connector role name (defaults to the standard name). | ||
| * @param {string} [params.region] - STS region. | ||
| * @param {string} [params.operator] - operator identity (email); used as the RoleSessionName so the | ||
| * customer's CloudTrail attributes each mutation to the person who ran it. | ||
| * @returns {Promise<{roleArn: string, accountId: string, credentials: object}>} | ||
| */ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (blocking): The empty catch swallows ALL exception classes (TypeError, credential expiration, throttling, SDK config errors) without any observable signal. If The Comment marker IS a sufficient fallback for ownership decisions, so this is not a safety bug. But silent, invisible failure in production code running in customer accounts is an observability gap that should not ship without at minimum a log line. Fix: Replace the empty catch with a warn/debug-level log: } catch (e) {
log.debug(`best-effort tagging of ${functionArn} failed: ${e.message}`);
}If no logger is in scope, at minimum narrow the catch to the expected |
||
| export async function assumeConnectorRole({ | ||
| accountId, | ||
| externalId, | ||
| roleName = EDGE_OPTIMIZE_DEFAULT_ROLE_NAME, | ||
| region = EDGE_OPTIMIZE_REGION, | ||
| operator, | ||
| }) { | ||
| if (!/^[0-9]{12}$/.test(String(accountId))) { | ||
| throw new Error('accountId must be a 12-digit AWS account ID'); | ||
|
|
@@ -135,7 +182,7 @@ export async function assumeConnectorRole({ | |
| const sts = new STSClient({ region }); | ||
| const response = await sts.send(new AssumeRoleCommand({ | ||
| RoleArn: roleArn, | ||
| RoleSessionName: SESSION_NAME, | ||
| RoleSessionName: sessionNameFor(operator), | ||
| ExternalId: externalId, | ||
| DurationSeconds: SESSION_DURATION_SECONDS, | ||
| })); | ||
|
|
@@ -323,6 +370,12 @@ export async function createOrigin( | |
| throw new Error(`An origin for ${originDomain} already exists as ${existingDomainOrigin.Id}; refusing to reuse a non-Edge Optimize origin`); | ||
| } | ||
|
|
||
| if (existing) { | ||
| // Reuse only our origin: our reserved Id must point at the EO domain. | ||
| // If it targets something else, it was not created by us (or was repurposed) — do not reuse it. | ||
| assertOwnedOrThrow('origin', EDGE_OPTIMIZE_ORIGIN_ID, existing.DomainName === originDomain); | ||
| } | ||
|
|
||
| if (existing) { | ||
| // Idempotent — but self-heal an origin created without the EO headers (earlier bug): patch its | ||
| // CustomHeaders to the desired set when they differ. Never wipe headers if none were supplied. | ||
|
|
@@ -397,6 +450,7 @@ export async function createCloudFrontFunction( | |
| distributionId, | ||
| targetedPaths = null, | ||
| region = EDGE_OPTIMIZE_REGION, | ||
| operator = undefined, | ||
| ) { | ||
| if (!hasText(defaultOriginId)) { | ||
| throw new Error('defaultOriginId is required'); | ||
|
|
@@ -414,12 +468,16 @@ export async function createCloudFrontFunction( | |
|
|
||
| // Look up the DEVELOPMENT stage to get its ETag (needed to update an existing function). | ||
| let existingEtag = null; | ||
| let functionArn = null; | ||
| let existingComment = ''; | ||
| try { | ||
| const desc = await client.send(new DescribeFunctionCommand({ | ||
| Name: functionName, | ||
| Stage: 'DEVELOPMENT', | ||
| })); | ||
| existingEtag = desc.ETag; | ||
| functionArn = desc.FunctionSummary?.FunctionMetadata?.FunctionARN ?? null; | ||
| existingComment = String(desc.FunctionSummary?.FunctionConfig?.Comment || ''); | ||
| } catch (err) { | ||
| if (err.name !== 'NoSuchFunctionExists') { | ||
| throw err; | ||
|
|
@@ -428,27 +486,45 @@ export async function createCloudFrontFunction( | |
|
|
||
| let etag; | ||
| if (existingEtag) { | ||
| // Reuse only our function: it must carry our Comment marker (checked before the update below | ||
| // overwrites the Comment) — a same-named function without it is not ours. | ||
| assertOwnedOrThrow('function', functionName, existingComment.includes(OWNER_COMMENT_MARKER)); | ||
| const updated = await client.send(new UpdateFunctionCommand({ | ||
| Name: functionName, | ||
| IfMatch: existingEtag, | ||
| FunctionConfig: functionConfig, | ||
| FunctionCode: code, | ||
| })); | ||
| etag = updated.ETag; | ||
| functionArn = updated.FunctionSummary?.FunctionMetadata?.FunctionARN ?? functionArn; | ||
| } else { | ||
| const created = await client.send(new CreateFunctionCommand({ | ||
| Name: functionName, | ||
| FunctionConfig: functionConfig, | ||
| FunctionCode: code, | ||
| })); | ||
| etag = created.ETag; | ||
| functionArn = created.FunctionSummary?.FunctionMetadata?.FunctionARN ?? functionArn; | ||
| } | ||
|
|
||
| await client.send(new PublishFunctionCommand({ | ||
| Name: functionName, | ||
| IfMatch: etag, | ||
| })); | ||
|
|
||
| // Tag the function (CloudFront functions are taggable) so reuse can verify it is ours. | ||
| // Best-effort: the Comment marker still identifies it as ours if tagging is unavailable. | ||
| if (functionArn) { | ||
| try { | ||
| await client.send(new TagResourceCommand({ | ||
| Resource: functionArn, | ||
| Tags: { Items: ownerTagPairs(operator) }, | ||
| })); | ||
| } catch (e) { | ||
| // best-effort tagging; the Comment marker is the fallback ownership signal | ||
| } | ||
| } | ||
|
|
||
| return { name: functionName, created: !existingEtag, stage: 'LIVE' }; | ||
| } | ||
|
|
||
|
|
@@ -602,6 +678,13 @@ export async function updateCacheSettings( | |
| let newPolicyId; | ||
| let reused = false; | ||
| if (existing) { | ||
| // Reuse only our clone: a policy matching the derived clone name must carry our Comment marker; | ||
| // if one with that exact name exists without it, it is not ours — refuse. | ||
| assertOwnedOrThrow( | ||
| 'cache policy', | ||
| clonedName, | ||
| String(existing.CachePolicy.CachePolicyConfig?.Comment || '').includes(OWNER_COMMENT_MARKER), | ||
| ); | ||
| newPolicyId = existing.CachePolicy.Id; | ||
| reused = true; | ||
| } else { | ||
|
|
@@ -755,6 +838,7 @@ export async function createLambdaAtEdge( | |
| distributionId, | ||
| originDomain = EDGE_OPTIMIZE_DEFAULT_ORIGIN_DOMAIN, | ||
| retryDelayMs = 5000, | ||
| operator, | ||
| } = {}, | ||
| ) { | ||
| if (!/^[0-9]{12}$/.test(String(accountId))) { | ||
|
|
@@ -778,6 +862,9 @@ export async function createLambdaAtEdge( | |
| const existing = await iam.send( | ||
| new GetRoleCommand({ RoleName: roleName }), | ||
| ); | ||
| // Reuse only our role: it must carry our owner tag before we modify its trust policy; | ||
| // a same-named role without it is not ours — refuse rather than repurpose it. | ||
| assertOwnedOrThrow('Lambda execution role', roleName, roleTagsOwned(existing.Role?.Tags)); | ||
| roleArn = existing.Role.Arn; | ||
| await iam.send(new UpdateAssumeRolePolicyCommand({ | ||
| RoleName: roleName, | ||
|
|
@@ -791,6 +878,7 @@ export async function createLambdaAtEdge( | |
| RoleName: roleName, | ||
| AssumeRolePolicyDocument: LAMBDA_TRUST_POLICY, | ||
| Description: 'Execution role for EdgeOptimize Lambda@Edge function', | ||
| Tags: ownerTagPairs(operator), | ||
| })); | ||
| roleArn = created.Role.Arn; | ||
| roleIsNew = true; | ||
|
|
@@ -845,6 +933,7 @@ export async function createLambdaAtEdge( | |
| Description: 'EdgeOptimize origin request/response handler (Lambda@Edge)', | ||
| Timeout: 5, | ||
| MemorySize: 128, | ||
| Tags: ownerTagsObject(operator), | ||
| })); | ||
| createdArn = created.FunctionArn; | ||
| lastErr = null; | ||
|
|
@@ -885,6 +974,14 @@ export async function createLambdaAtEdge( | |
| }; | ||
| } | ||
|
|
||
| // Reuse only our function: before reusing an existing Active one, confirm it runs as our exec | ||
| // role; if it points at a different role it was not created by us — refuse. | ||
| assertOwnedOrThrow( | ||
| 'Lambda@Edge function', | ||
| lambdaName, | ||
| String(cfg.Role || '').endsWith(`/${roleName}`), | ||
| ); | ||
|
|
||
| // Active and idle. If a numbered version already exists, reuse it (idempotent). | ||
| const existingVersion = await getLatestLambdaVersion(lambda, lambdaName); | ||
| if (existingVersion) { | ||
|
|
@@ -1199,6 +1296,11 @@ async function fetchEdgeOptimizeHeaders(url, userAgent) { | |
| } | ||
| } | ||
|
|
||
| // A realistic desktop-browser User-Agent for the "human" probe. A bare `Mozilla/5.0` can be | ||
| // misclassified by bot-detection, making the human leg look bot-like and the verify falsely fail. | ||
| const DEFAULT_HUMAN_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' | ||
| + '(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'; | ||
|
|
||
| /** | ||
| * Verify Edge Optimize routing end-to-end by fetching the distribution domain as an agentic bot | ||
| * and as a human, then inspecting the `x-edgeoptimize-*` headers. Mirrors the standalone wizard's | ||
|
|
@@ -1207,15 +1309,16 @@ async function fetchEdgeOptimizeHeaders(url, userAgent) { | |
| * the page is NOT optimised, which is NOT success. | ||
| * | ||
| * @param {string} url - the URL to probe (typically `https://<distribution-domain>/`). | ||
| * @param {object} [options] | ||
| * @param {string} [options.humanUa] - User-Agent for the human probe (defaults to a browser UA). | ||
| * @returns {Promise<{passed: boolean, requestId: string|null, | ||
| * details: {bot: object, human: object}}>} | ||
| */ | ||
| export async function verifyRouting(url) { | ||
| export async function verifyRouting(url, { humanUa = DEFAULT_HUMAN_UA } = {}) { | ||
| if (!hasText(url)) { | ||
| throw new Error('url is required'); | ||
| } | ||
| const botUa = 'chatgpt-user'; | ||
| const humanUa = 'Mozilla/5.0'; | ||
| const [bot, human] = await Promise.all([ | ||
| fetchEdgeOptimizeHeaders(url, botUa), | ||
| fetchEdgeOptimizeHeaders(url, humanUa), | ||
|
|
@@ -1328,7 +1431,7 @@ async function isBehaviorAlreadyAssociated(client, distributionId, pathPattern) | |
| export async function runDeployStep( | ||
| credentials, | ||
| { | ||
| distributionId, originId, behavior, originDomain, originHeaders, accountId, | ||
| distributionId, originId, behavior, originDomain, originHeaders, accountId, operator, | ||
| }, | ||
| region = EDGE_OPTIMIZE_REGION, | ||
| ) { | ||
|
|
@@ -1362,7 +1465,7 @@ export async function runDeployStep( | |
| if (await isRoutingFunctionLive(client, distributionId)) { | ||
| byKey('function').status = 'done'; | ||
| } else { | ||
| await createCloudFrontFunction(credentials, originId, distributionId, null, region); | ||
| await createCloudFrontFunction(credentials, originId, distributionId, null, region, operator); | ||
| byKey('function').status = 'done'; | ||
| } | ||
| } catch (err) { | ||
|
|
@@ -1401,7 +1504,9 @@ export async function runDeployStep( | |
| const created = await createLambdaAtEdge( | ||
| credentials, | ||
| accountId, | ||
| { region, distributionId, originDomain }, | ||
| { | ||
| region, distributionId, originDomain, operator, | ||
| }, | ||
| ); | ||
| if (created.status === 'ready') { | ||
| lambdaVersionArn = created.versionArn; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue (blocking): The
assertOwnedOrThrowfor the Lambda function (further down at line ~978) runs AFTER the Pending/InProgress early-return. When a non-owned Lambda is still initializing (State=Pending or LastUpdateStatus=InProgress), the function returns{ status: "provisioning" }without ever checking ownership. On the next poll (once Active), it throws.The caller sees "provisioning" (implying our deploy is in progress) then a hard ownership error on the next call. This is confusing UX and a potential false-positive in orchestration that treats "provisioning" as "we are making progress."
The IAM role IS correctly guarded before this point, so safety is preserved (we never publish code into a non-owned function). But the behavioral inconsistency should be resolved.
Fix: Move the ownership check (
assertOwnedOrThrowoncfg.Role) above the Pending/InProgress early-return, or document explicitly in a comment why skipping the guard in that state is acceptable (e.g. the role check already ran, the Lambda cannot be published into during Pending).