diff --git a/packages/amplify-graphql-schema-generator/src/__tests__/__snapshots__/generate-ts-data-schema.test.ts.snap b/packages/amplify-graphql-schema-generator/src/__tests__/__snapshots__/generate-ts-data-schema.test.ts.snap index c5091c7356..44cf4557e0 100644 --- a/packages/amplify-graphql-schema-generator/src/__tests__/__snapshots__/generate-ts-data-schema.test.ts.snap +++ b/packages/amplify-graphql-schema-generator/src/__tests__/__snapshots__/generate-ts-data-schema.test.ts.snap @@ -7,14 +7,14 @@ import { a } from \\"@aws-amplify/data-schema\\"; export const schema = a.schema({ \\"User\\": a.model({ - id: a.string().required(), - name: a.string() + \\"id\\": a.string().required(), + \\"name\\": a.string() }).identifier([ \\"id\\" ]), \\"Profile\\": a.model({ - id: a.string().required(), - details: a.string() + \\"id\\": a.string().required(), + \\"details\\": a.string() }).identifier([ \\"id\\" ]) @@ -29,22 +29,22 @@ import { a } from \\"@aws-amplify/data-schema\\"; export const schema = a.schema({ \\"Table\\": a.model({ - id: a.string().required(), - field1: a.string(), - field2: a.string(), - field3: a.integer(), - field4: a.float(), - field5: a.boolean(), - field6: a.id(), - field7: a.date(), - field8: a.time(), - field9: a.datetime(), - field10: a.timestamp(), - field11: a.json(), - field12: a.email(), - field13: a.phone(), - field14: a.url(), - field15: a.ipAddress() + \\"id\\": a.string().required(), + \\"field1\\": a.string(), + \\"field2\\": a.string(), + \\"field3\\": a.integer(), + \\"field4\\": a.float(), + \\"field5\\": a.boolean(), + \\"field6\\": a.id(), + \\"field7\\": a.date(), + \\"field8\\": a.time(), + \\"field9\\": a.datetime(), + \\"field10\\": a.timestamp(), + \\"field11\\": a.json(), + \\"field12\\": a.email(), + \\"field13\\": a.phone(), + \\"field14\\": a.url(), + \\"field15\\": a.ipAddress() }).identifier([ \\"id\\" ]) @@ -67,8 +67,8 @@ export const schema = configure({ } }).schema({ \\"User\\": a.model({ - id: a.string().required(), - name: a.string() + \\"id\\": a.string().required(), + \\"name\\": a.string() }).identifier([ \\"id\\" ]) @@ -83,13 +83,13 @@ import { a } from \\"@aws-amplify/data-schema\\"; export const schema = a.schema({ \\"User\\": a.model({ - id: a.string().required(), - name: a.string(), - status: a.ref(\\"UserStatus\\") + \\"id\\": a.string().required(), + \\"name\\": a.string(), + \\"status\\": a.ref(\\"UserStatus\\") }).identifier([ \\"id\\" ]), - UserStatus: a.enum([ + \\"UserStatus\\": a.enum([ \\"ACTIVE\\", \\"INACTIVE\\" ]) @@ -104,24 +104,24 @@ import { a } from \\"@aws-amplify/data-schema\\"; export const schema = a.schema({ \\"User\\": a.model({ - id: a.string().required(), - name: a.string(), - status: a.ref(\\"UserStatus\\").required() + \\"id\\": a.string().required(), + \\"name\\": a.string(), + \\"status\\": a.ref(\\"UserStatus\\").required() }).identifier([ \\"id\\" ]), \\"Test\\": a.model({ - id: a.string().required(), - name: a.string(), - age: a.ref(\\"TestAge\\").required() + \\"id\\": a.string().required(), + \\"name\\": a.string(), + \\"age\\": a.ref(\\"TestAge\\").required() }).identifier([ \\"id\\" ]), - UserStatus: a.enum([ + \\"UserStatus\\": a.enum([ \\"ACTIVE\\", \\"INACTIVE\\" ]), - TestAge: a.enum([ + \\"TestAge\\": a.enum([ \\"Above18\\", \\"Below18\\" ]) @@ -136,18 +136,18 @@ import { a } from \\"@aws-amplify/data-schema\\"; export const schema = a.schema({ \\"User\\": a.model({ - id: a.string().required(), - name: a.string(), - statsu1: a.ref(\\"UserStatus1\\"), - status: a.ref(\\"UserStatus\\").required() + \\"id\\": a.string().required(), + \\"name\\": a.string(), + \\"statsu1\\": a.ref(\\"UserStatus1\\"), + \\"status\\": a.ref(\\"UserStatus\\").required() }).identifier([ \\"id\\" ]), - UserStatus1: a.enum([ + \\"UserStatus1\\": a.enum([ \\"ACTIVE\\", \\"INACTIVE\\" ]), - UserStatus: a.enum([ + \\"UserStatus\\": a.enum([ \\"ACTIVE\\", \\"INACTIVE\\" ]) @@ -162,13 +162,13 @@ import { a } from \\"@aws-amplify/data-schema\\"; export const schema = a.schema({ \\"User\\": a.model({ - id: a.string().required(), - name: a.string(), - status: a.ref(\\"UserStatus\\").required() + \\"id\\": a.string().required(), + \\"name\\": a.string(), + \\"status\\": a.ref(\\"UserStatus\\").required() }).identifier([ \\"id\\" ]), - UserStatus: a.enum([ + \\"UserStatus\\": a.enum([ \\"ACTIVE\\", \\"INACTIVE\\" ]) @@ -183,20 +183,20 @@ import { a } from \\"@aws-amplify/data-schema\\"; export const schema = a.schema({ \\"User\\": a.model({ - id: a.string().required(), - name: a.string(), - status: a.ref(\\"UserStatus\\").required() + \\"id\\": a.string().required(), + \\"name\\": a.string(), + \\"status\\": a.ref(\\"UserStatus\\").required() }).identifier([ \\"id\\" ]), \\"Test\\": a.model({ - id: a.string().required(), - name: a.string(), - status: a.ref(\\"UserStatus\\").required() + \\"id\\": a.string().required(), + \\"name\\": a.string(), + \\"status\\": a.ref(\\"UserStatus\\").required() }).identifier([ \\"id\\" ]), - UserStatus: a.enum([ + \\"UserStatus\\": a.enum([ \\"ACTIVE\\", \\"INACTIVE\\" ]) @@ -231,8 +231,8 @@ export const schema = configure({ } }).schema({ \\"User\\": a.model({ - id: a.string().required(), - name: a.string() + \\"id\\": a.string().required(), + \\"name\\": a.string() }).identifier([ \\"id\\" ]) @@ -277,14 +277,14 @@ export const schema = configure({ } }).schema({ \\"User\\": a.model({ - id: a.string().required(), - name: a.string() + \\"id\\": a.string().required(), + \\"name\\": a.string() }).identifier([ \\"id\\" ]), \\"Profile\\": a.model({ - id: a.string().required(), - details: a.string() + \\"id\\": a.string().required(), + \\"details\\": a.string() }).identifier([ \\"id\\" ]) @@ -307,14 +307,14 @@ export const schema = configure({ } }).schema({ \\"User\\": a.model({ - id: a.string().required(), - name: a.string() + \\"id\\": a.string().required(), + \\"name\\": a.string() }).identifier([ \\"id\\" ]), \\"Profile\\": a.model({ - id: a.string().required(), - details: a.string() + \\"id\\": a.string().required(), + \\"details\\": a.string() }).identifier([ \\"id\\" ]) @@ -338,14 +338,14 @@ export const schema = configure({ } }).schema({ \\"User\\": a.model({ - id: a.string().required(), - name: a.string() + \\"id\\": a.string().required(), + \\"name\\": a.string() }).identifier([ \\"id\\" ]), \\"Profile\\": a.model({ - id: a.string().required(), - details: a.string() + \\"id\\": a.string().required(), + \\"details\\": a.string() }).identifier([ \\"id\\" ]) @@ -368,9 +368,9 @@ export const schema = configure({ } }).schema({ \\"CoffeeQueue\\": a.model({ - id: a.integer().default(), - name: a.string(), - orderNumber: a.integer().default() + \\"id\\": a.integer().default(), + \\"name\\": a.string(), + \\"orderNumber\\": a.integer().default() }).identifier([ \\"id\\" ]) @@ -415,8 +415,8 @@ export const schema = configure({ } }).schema({ \\"User\\": a.model({ - id: a.string().required(), - name: a.string() + \\"id\\": a.string().required(), + \\"name\\": a.string() }).identifier([ \\"id\\" ]) @@ -462,8 +462,8 @@ export const schema = configure({ } }).schema({ \\"User\\": a.model({ - id: a.string().required(), - name: a.string() + \\"id\\": a.string().required(), + \\"name\\": a.string() }).identifier([ \\"id\\" ]) @@ -509,8 +509,8 @@ export const schema = configure({ } }).schema({ \\"Person\\": a.model({ - id: a.string().required(), - name: a.string() + \\"id\\": a.string().required(), + \\"name\\": a.string() }).identifier([ \\"id\\" ]) diff --git a/packages/amplify-graphql-schema-generator/src/__tests__/generate-ts-data-schema.test.ts b/packages/amplify-graphql-schema-generator/src/__tests__/generate-ts-data-schema.test.ts index 376b578e65..c1e7e9a08b 100644 --- a/packages/amplify-graphql-schema-generator/src/__tests__/generate-ts-data-schema.test.ts +++ b/packages/amplify-graphql-schema-generator/src/__tests__/generate-ts-data-schema.test.ts @@ -521,3 +521,78 @@ describe('Type name conversions', () => { ); }); }); + +describe('Identifier sanitization', () => { + it('should sanitize column names with shell metacharacters', () => { + const dbschema = new Schema(new Engine('MySQL')); + const model = new Model('User'); + model.addField(new Field('id', { kind: 'NonNull', type: { kind: 'Scalar', name: 'String' } })); + model.addField(new Field("require('child_process').execSync('echo pwned')", { kind: 'Scalar', name: 'String' })); + model.setPrimaryKey(['id']); + dbschema.addModel(model); + + const graphqlSchema = generateTypescriptDataSchema(dbschema); + // The malicious column name with special characters must not appear verbatim in the output + expect(graphqlSchema).not.toContain("require('child_process')"); + expect(graphqlSchema).not.toContain("execSync('echo pwned')"); + // It should be sanitized: special chars replaced with underscores, safely quoted as a string literal + expect(graphqlSchema).toContain('"require_child_process_execSync_echo_pwned_"'); + }); + + it('should sanitize column names with square brackets and special characters', () => { + const dbschema = new Schema(new Engine('MySQL')); + const model = new Model('User'); + model.addField(new Field('id', { kind: 'NonNull', type: { kind: 'Scalar', name: 'String' } })); + model.addField(new Field('[malicious](injection)', { kind: 'Scalar', name: 'String' })); + model.setPrimaryKey(['id']); + dbschema.addModel(model); + + const graphqlSchema = generateTypescriptDataSchema(dbschema); + expect(graphqlSchema).not.toContain('[malicious]'); + expect(graphqlSchema).toContain('"malicious_injection_"'); + }); + + it('should sanitize enum type names with special characters', () => { + const dbschema = new Schema(new Engine('Postgres')); + const model = new Model('User'); + model.addField(new Field('id', { kind: 'NonNull', type: { kind: 'Scalar', name: 'String' } })); + model.addField(new Field('status', { kind: 'Enum', name: "evil';DROP TABLE", values: ['A', 'B'] })); + model.setPrimaryKey(['id']); + dbschema.addModel(model); + + const graphqlSchema = generateTypescriptDataSchema(dbschema); + // The original unsanitized name should not appear + expect(graphqlSchema).not.toContain("evil';DROP TABLE"); + // Sanitized and PascalCased enum name should be used as property key and ref target + expect(graphqlSchema).toContain('"Evil_DROP_TABLE"'); + // The a.ref() should reference the sanitized name + expect(graphqlSchema).toContain('a.ref("Evil_DROP_TABLE")'); + }); + + it('should produce a fallback name for column names with no alphabetic characters', () => { + const dbschema = new Schema(new Engine('MySQL')); + const model = new Model('User'); + model.addField(new Field('id', { kind: 'NonNull', type: { kind: 'Scalar', name: 'String' } })); + model.addField(new Field('!@#$%^&*()', { kind: 'Scalar', name: 'String' })); + model.setPrimaryKey(['id']); + dbschema.addModel(model); + + const graphqlSchema = generateTypescriptDataSchema(dbschema); + // Should use fallback "field" for fields with no alpha chars + expect(graphqlSchema).toContain('"field"'); + }); + + it('should use string literals for property keys (not raw identifiers)', () => { + const dbschema = new Schema(new Engine('MySQL')); + const model = new Model('User'); + model.addField(new Field('id', { kind: 'NonNull', type: { kind: 'Scalar', name: 'String' } })); + model.addField(new Field('normal_name', { kind: 'Scalar', name: 'String' })); + model.setPrimaryKey(['id']); + dbschema.addModel(model); + + const graphqlSchema = generateTypescriptDataSchema(dbschema); + // Property keys should be string-quoted + expect(graphqlSchema).toContain('"normal_name"'); + expect(graphqlSchema).toContain('"id"'); + }); +}); diff --git a/packages/amplify-graphql-schema-generator/src/ts-schema-generator/helpers.ts b/packages/amplify-graphql-schema-generator/src/ts-schema-generator/helpers.ts index 6db8e26998..4fb9af22fc 100644 --- a/packages/amplify-graphql-schema-generator/src/ts-schema-generator/helpers.ts +++ b/packages/amplify-graphql-schema-generator/src/ts-schema-generator/helpers.ts @@ -20,6 +20,25 @@ const GQL_TYPESCRIPT_DATA_SCHEMA_TYPE_MAP = { awsipaddress: 'ipAddress', }; +/** + * Sanitizes a database identifier (column name, enum name) to produce a safe TypeScript property name. + * Matches the behavior of `cleanMappedName` in the GraphQL-SDL output path. + * - If the name has no alphabetic characters, a fallback is generated. + * - Leading non-alphabetic characters are stripped. + * - Non-alphanumeric characters (except underscores) are replaced with underscores. + */ +const sanitizeIdentifier = (name: string, isField: boolean = false): string => { + if (!name?.match(/[a-zA-Z]/)) { + const suffix = name?.replace(/[^0-9]+/g, '') ?? ''; + return isField ? `field${suffix}` : `Model${suffix}`; + } + + return name + .replace(/^[^a-zA-Z]+/, '') + .replace(/[^a-zA-Z0-9_]+/g, '_') + .trim(); +}; + /** * Creates a typescript data schema property from internal SQL schema representation * Example typescript data schema property output: `id: a.string().required()` @@ -28,7 +47,10 @@ const GQL_TYPESCRIPT_DATA_SCHEMA_TYPE_MAP = { */ const createProperty = (field: Field): ts.Node => { const typeExpression = createDataType(field); - return ts.factory.createPropertyAssignment(ts.factory.createIdentifier(field.name), typeExpression as ts.Expression); + return ts.factory.createPropertyAssignment( + ts.factory.createStringLiteral(sanitizeIdentifier(field.name, true)), + typeExpression as ts.Expression, + ); }; /** @@ -63,7 +85,7 @@ const createDataType = (field: Field): ts.Node => { return ts.factory.createCallExpression( ts.factory.createIdentifier(`${TYPESCRIPT_DATA_SCHEMA_CONSTANTS.REFERENCE_A}.${TYPESCRIPT_DATA_SCHEMA_CONSTANTS.REF_METHOD}`), undefined, - [ts.factory.createStringLiteral(toPascalCase([field.type.name]))], + [ts.factory.createStringLiteral(toPascalCase([sanitizeIdentifier(field.type.name)]))], ); } @@ -117,7 +139,7 @@ const createEnums = (type: EnumType): ts.Node => { ), ], ); - return ts.factory.createPropertyAssignment(ts.factory.createIdentifier(toPascalCase([type.name])), typeExpression); + return ts.factory.createPropertyAssignment(ts.factory.createStringLiteral(toPascalCase([sanitizeIdentifier(type.name)])), typeExpression); }; const createModel = (model: Model): ts.Node => { @@ -214,10 +236,12 @@ export const createSchema = (schema: Schema, config?: DataSourceGenerateConfig): // to eliminate duplicate definition of enums in case where same enum is referenced in 2 differed models const seenEnums = new Set(); const uniqueEnums = combinedEnums.filter((node) => { - if (seenEnums.has(node['name'].escapedText)) { + const name = node['name']; + const key = name.escapedText ?? name.text; + if (seenEnums.has(key)) { return false; } else { - seenEnums.add(node['name'].escapedText); + seenEnums.add(key); return true; } });