diff --git a/README.md b/README.md index ec6469fa..ec5146e0 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,13 @@ [![Open Bugs](https://img.shields.io/github/issues/aws-amplify/amplify-codegen-ui/bug?color=d73a4a&label=bugs)](https://github.com/aws-amplify/amplify-codegen-ui/issues?q=is%3Aissue+is%3Aopen+label%3Abug) [![Feature Requests](https://img.shields.io/github/issues/aws-amplify/amplify-codegen-ui/feature-request?color=ff9001&label=feature%20requests)](https://github.com/aws-amplify/amplify-codegen-ui/issues?q=is%3Aissue+label%3Afeature-request+is%3Aopen) +> [!IMPORTANT] +> **Maintenance mode.** Amplify Gen 1 — including Amplify Studio and this UI +> component generator — is in maintenance mode. As of **May 1, 2026** it receives +> only critical bug fixes and security patches; **end of life is May 1, 2027**. +> See the [official announcement](https://github.com/aws-amplify/amplify-cli/issues/14881) +> and the [Gen 1 → Gen 2 migration guide](https://docs.amplify.aws/react/start/migrate-to-gen2/). + Generate React components for use in an AWS Amplify project. ## Usage diff --git a/packages/codegen-ui-react/lib/__tests__/react-theme-studio-template-renderer.test.ts b/packages/codegen-ui-react/lib/__tests__/react-theme-studio-template-renderer.test.ts index 3c179cab..d047520c 100644 --- a/packages/codegen-ui-react/lib/__tests__/react-theme-studio-template-renderer.test.ts +++ b/packages/codegen-ui-react/lib/__tests__/react-theme-studio-template-renderer.test.ts @@ -13,11 +13,13 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { isIdentifier, isStringLiteral } from 'typescript'; import { StudioTemplateRendererFactory, StudioTheme } from '@aws-amplify/codegen-ui'; import { ScriptTarget, ScriptKind, ReactRenderConfig } from '..'; import { ReactThemeStudioTemplateRenderer, ReactThemeStudioTemplateRendererOptions, + buildThemePropertyName, } from '../react-theme-studio-template-renderer'; import { loadSchemaFromJSONFile } from './__utils__'; @@ -91,4 +93,89 @@ describe('react theme renderer tests', () => { expect(themeObject.breakpoints.defaultBreakpoint).toBe('base'); }); }); + + // SECURITY: theme keys are attacker-controllable and reach factory.createIdentifier() + // (CVE-2025-4318 class). buildThemePropertyName must emit only valid identifiers as + // identifiers and everything else as an inert quoted string-literal key. + describe('buildThemePropertyName', () => { + it.each([ + ['simple identifier', 'colors'], + ['underscore prefix', '_private'], + ['dollar prefix', '$ref'], + ['digits after letter', 'space2'], + ])('emits a valid identifier (%s) as an Identifier node', (_label, key) => { + const node = buildThemePropertyName(key); + expect(isIdentifier(node)).toBe(true); + expect(isStringLiteral(node)).toBe(false); + }); + + it.each([ + ['empty string', ''], + ['leading digit', '2xl'], + ['css custom property', '--spacing-1'], + ['hyphenated css key', 'font-size'], + ['object-literal breakout', '}};process.exit(1);//'], + ['indirect eval', '(0,eval)("x")'], + ['string-concat obfuscation', '"a"+"b"'], + ['null-byte smuggling', 'foo\x00bar'], + ])('emits a non-identifier key (%s) as a StringLiteral node', (_label, key) => { + const node = buildThemePropertyName(key); + expect(isStringLiteral(node)).toBe(true); + expect(isIdentifier(node)).toBe(false); + }); + }); + + describe('theme keys that are not valid identifiers', () => { + const rendererFactory = new StudioTemplateRendererFactory( + (t: StudioTheme) => new ReactThemeStudioTemplateRenderer(t, {}), + ); + + const renderWithKey = (key: string, values: StudioTheme['values']): string => + rendererFactory.buildRenderer({ name: 'MyTheme', values }).renderComponent().componentText; + + it('emits a legitimate non-identifier CSS key as a quoted string-literal key', () => { + // Documents the legitimate path: CSS custom-property-style keys are valid data, + // just not valid JS identifiers, so they are emitted as quoted keys. + const componentText = renderWithKey('font-size', [{ key: 'font-size', value: { value: 'red' } }]); + expect(componentText).toMatch(/['"]font-size['"]\s*:/); + }); + + const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + // Assert `key` is emitted only as a quoted property name (either quote style), + // and never as a bare (unquoted) property key that would execute as source. + const expectNeutralized = (componentText: string, key: string): void => { + const escaped = escapeRe(key); + expect(componentText).toMatch(new RegExp(`['"]${escaped}['"]\\s*:`)); + expect(componentText).not.toMatch(new RegExp(`(? { + expectNeutralized(renderWithKey(key, [{ key, value: { value: 'red' } }]), key); + }); + + // Per-sink coverage: buildThemeValues (nested token keys) and buildThemeBreakpointValues + // (breakpoint token keys) both delegate to buildThemePropertyName. Exercise each so a + // future refactor that inlines the call differently cannot silently regress. + it('neutralizes attack key at the nested buildThemeValues sink', () => { + const attackKey = '(0,eval)("x")'; + const componentText = renderWithKey('tokens', [ + { key: 'tokens', value: { children: [{ key: attackKey, value: { value: 'red' } }] } }, + ]); + expectNeutralized(componentText, attackKey); + }); + + it('neutralizes attack key at the buildThemeBreakpointValues sink', () => { + const attackKey = '(0,eval)("x")'; + const componentText = renderWithKey('breakpoints', [ + { key: 'breakpoints', value: { children: [{ key: attackKey, value: { value: '480' } }] } }, + ]); + expectNeutralized(componentText, attackKey); + }); + }); }); diff --git a/packages/codegen-ui-react/lib/react-theme-studio-template-renderer.ts b/packages/codegen-ui-react/lib/react-theme-studio-template-renderer.ts index 6f6d4adc..a60b8bb3 100644 --- a/packages/codegen-ui-react/lib/react-theme-studio-template-renderer.ts +++ b/packages/codegen-ui-react/lib/react-theme-studio-template-renderer.ts @@ -18,6 +18,7 @@ import { factory, EmitHint, ExportAssignment, + Identifier, ObjectLiteralExpression, StringLiteral, PropertyAssignment, @@ -32,6 +33,7 @@ import { InvalidInputError, handleCodegenErrors, } from '@aws-amplify/codegen-ui'; +import { SIMPLE_JS_IDENTIFIER_RE } from './utils/identifiers'; import { ReactRenderConfig, scriptKindToFileExtensionNonReact } from './react-render-config'; import { ImportCollection, ImportValue } from './imports'; import { ReactOutputManager } from './react-output-manager'; @@ -47,6 +49,23 @@ export type ReactThemeStudioTemplateRendererOptions = { renderDefaultTheme?: boolean; }; +// SECURITY: passing attacker-controlled StudioTheme keys directly to +// factory.createIdentifier() allows code injection in generated .tsx output +// (CVE-2025-4318 class -- theme-renderer surface). createIdentifier() emits its +// argument verbatim as source, so only valid JS identifiers are emitted as +// identifiers; any other key is emitted as a (properly escaped) string-literal +// key instead. +// +// Note: unlike escapePropertyValue()/buildBindingEvent() in the component path +// -- which fall back to '' (an empty identifier) -- we intentionally fall back +// to the full key as a string literal. This keeps the generated object literal +// well-formed (valid syntax, no empty identifier) and preserves legitimate +// non-identifier CSS token keys such as '--spacing-1' and '2xl'. Do not +// normalize this to the '' fallback. +export function buildThemePropertyName(key: string): Identifier | StringLiteral { + return SIMPLE_JS_IDENTIFIER_RE.test(key) ? factory.createIdentifier(key) : factory.createStringLiteral(key); +} + export class ReactThemeStudioTemplateRenderer extends StudioTemplateRenderer< string, StudioTheme, @@ -162,9 +181,9 @@ export class ReactThemeStudioTemplateRenderer extends StudioTemplateRenderer< splitAndBuildThemeValues(values: StudioThemeValues[]): PropertyAssignment[] { return values.map(({ key, value }) => { if (key !== 'breakpoints') { - return factory.createPropertyAssignment(factory.createIdentifier(key), this.buildThemeValue(value)); + return factory.createPropertyAssignment(buildThemePropertyName(key), this.buildThemeValue(value)); } - return factory.createPropertyAssignment(factory.createIdentifier(key), this.buildThemeBreakpointValue(value)); + return factory.createPropertyAssignment(buildThemePropertyName(key), this.buildThemeBreakpointValue(value)); }); } @@ -178,7 +197,7 @@ export class ReactThemeStudioTemplateRenderer extends StudioTemplateRenderer< */ private buildThemeValues(values: StudioThemeValues[]): PropertyAssignment[] { return values.map(({ key, value }) => - factory.createPropertyAssignment(factory.createIdentifier(key), this.buildThemeValue(value)), + factory.createPropertyAssignment(buildThemePropertyName(key), this.buildThemeValue(value)), ); } @@ -207,7 +226,7 @@ export class ReactThemeStudioTemplateRenderer extends StudioTemplateRenderer< */ buildThemeBreakpointValues(values: StudioThemeValues[]): PropertyAssignment[] { return values.map(({ key, value }) => - factory.createPropertyAssignment(factory.createIdentifier(key), this.buildThemeBreakpointValue(value)), + factory.createPropertyAssignment(buildThemePropertyName(key), this.buildThemeBreakpointValue(value)), ); } diff --git a/packages/codegen-ui-react/lib/utils/identifiers.ts b/packages/codegen-ui-react/lib/utils/identifiers.ts new file mode 100644 index 00000000..9e182a51 --- /dev/null +++ b/packages/codegen-ui-react/lib/utils/identifiers.ts @@ -0,0 +1,25 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"). + You may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +/** + * Matches a simple JS identifier -- no dot-paths, no computed access. + * + * SECURITY: values that fail this test must not be passed to + * factory.createIdentifier(), which emits its argument verbatim as source and + * would otherwise allow code injection into generated output + * (CVE-2025-4318 class). + */ +export const SIMPLE_JS_IDENTIFIER_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; diff --git a/packages/codegen-ui-react/lib/workflow/events.ts b/packages/codegen-ui-react/lib/workflow/events.ts index e834c047..0769ba93 100644 --- a/packages/codegen-ui-react/lib/workflow/events.ts +++ b/packages/codegen-ui-react/lib/workflow/events.ts @@ -19,6 +19,7 @@ import { getActionIdentifier } from './action'; import { isBoundEvent, isActionEvent } from '../react-component-render-helper'; import keywords from '../keywords'; import { Primitive, PrimitiveLevelPropConfiguration } from '../primitive'; +import { SIMPLE_JS_IDENTIFIER_RE } from '../utils/identifiers'; /* * Temporary hardcoded mapping of generic to react events, long-term this will be exported by amplify-ui. @@ -77,7 +78,7 @@ export function buildBindingEvent( if (keywords.has(bindingEvent)) { bindingEvent = `${bindingEvent}Prop`; } - const isValidIdentifier = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(bindingEvent); + const isValidIdentifier = SIMPLE_JS_IDENTIFIER_RE.test(bindingEvent); const sanitizedBindingEvent = isValidIdentifier ? bindingEvent : ''; const expr = factory.createIdentifier(sanitizedBindingEvent); return factory.createJsxAttribute(