diff --git a/__test__/query-injection.spec.ts b/__test__/query-injection.spec.ts new file mode 100644 index 000000000..dfcdbe143 --- /dev/null +++ b/__test__/query-injection.spec.ts @@ -0,0 +1,125 @@ +import { buildIndexExpr, IIndexOnParams, Query } from '../src'; +import { BuildIndexQueryError, BuildQueryError } from '../src/exceptions/ottoman-errors'; +import { WhereClauseException } from '../src/query/exceptions'; + +describe('Test N1QL injection hardening', () => { + test('A string value containing a double quote cannot terminate the literal', async () => { + const injected = 'x" OR 1=1 OR name="'; + const query = new Query({}, 'travel-sample').select('*').where({ name: injected }).build(); + + // The value must survive as a single escaped literal, not as extra N1QL syntax. + expect(query).toStrictEqual(`SELECT * FROM \`travel-sample\` WHERE name=${JSON.stringify(injected)}`); + expect(query).not.toContain('OR 1=1 OR name=""'); + }); + + test('Backslashes in a value are preserved instead of being stripped', async () => { + const windowsPath = 'C:\\Users\\bob'; + const query = new Query({}, 'travel-sample').select('*').where({ path: windowsPath }).build(); + + expect(query).toStrictEqual(`SELECT * FROM \`travel-sample\` WHERE \`path\`=${JSON.stringify(windowsPath)}`); + }); + + test('A quote inside a USE KEYS value cannot introduce another key', async () => { + const injected = 'a" , "b'; + const query = new Query({}, 'travel-sample').select('*').useKeys([injected]).build(); + + expect(query).toStrictEqual(`SELECT * FROM \`travel-sample\` USE KEYS ${JSON.stringify([injected])}`); + }); + + test('A quote inside a comparison value cannot terminate the literal', async () => { + const injected = '%57%" OR "1"="1'; + const query = new Query({}, 'travel-sample') + .select('*') + .where({ address: { $like: injected } }) + .build(); + + expect(query).toStrictEqual(`SELECT * FROM \`travel-sample\` WHERE address LIKE ${JSON.stringify(injected)}`); + }); + + test('An ORDER BY direction that is not ASC or DESC is rejected', async () => { + const build = () => + new Query({}, 'travel-sample') + .select('*') + .orderBy({ name: 'ASC, (SELECT 1)' as any }) + .build(); + + expect(build).toThrow(BuildQueryError); + }); + + test('An index ON sort direction that is not ASC or DESC is rejected', async () => { + const on = [{ name: 'callsign', sort: 'ASC"] , ["injected' }] as unknown as IIndexOnParams[]; + + expect(() => buildIndexExpr('travel-sample', 'CREATE', 'idx_test', on)).toThrow(BuildQueryError); + }); + + test('Valid sort directions are still accepted and normalised', async () => { + const query = new Query({}, 'travel-sample').select('*').orderBy({ name: 'DESC' }).build(); + + expect(query).toStrictEqual('SELECT * FROM `travel-sample` ORDER BY name DESC'); + }); + + test('A WHERE key that is not a field path is rejected', async () => { + const build = () => new Query({}, 'travel-sample').select('*').where({ 'a) OR 1=1 --': 'v' }).build(); + + expect(build).toThrow(WhereClauseException); + }); + + test('A WHERE key that is not a field path is rejected for comparison operators too', async () => { + const build = () => + new Query({}, 'travel-sample') + .select('*') + .where({ 'a) OR 1=1 --': { $gt: 1 } }) + .build(); + + expect(build).toThrow(WhereClauseException); + }); + + test('Legitimate field paths are still accepted', async () => { + const query = new Query({}, 'travel-sample') + .select('*') + .where({ 'address.city': 'Paris', 'numbers[0]': 3, 'travel-sample.callsign': { $like: '%AF%' } }) + .build(); + + expect(query).toStrictEqual( + 'SELECT * FROM `travel-sample` WHERE address.city="Paris" AND numbers[0]=3 AND `travel-sample`.callsign LIKE "%AF%"', + ); + }); + + test('A quote in a collection-operator literal cannot terminate it', async () => { + const query = new Query({}, 'travel-sample t') + .select('*') + .where({ ['"a" OR 1=1 OR "b"']: { $within: { $field: 't' } } }) + .build(); + + expect(query).toStrictEqual( + `SELECT * FROM \`travel-sample\` t WHERE ${JSON.stringify('a" OR 1=1 OR "b')} WITHIN t`, + ); + }); + + test('A backtick in an index name cannot close the identifier quoting', async () => { + const index = buildIndexExpr('travel-sample', 'DROP', 'idx` ; DROP INDEX `other'); + + expect(index).toStrictEqual('DROP INDEX `travel-sample`.`idx`` ; DROP INDEX ``other`'); + }); + + test('A backtick in the collection name cannot close the identifier quoting', async () => { + const index = buildIndexExpr('bucket` ; SELECT 1 FROM `x', 'DROP', 'idx_test'); + + expect(index).toStrictEqual('DROP INDEX `bucket`` ; SELECT 1 FROM ``x`.`idx_test`'); + }); + + test('An unsupported index statement is rejected', async () => { + expect(() => buildIndexExpr('travel-sample', 'DROP INDEX x; CREATE' as any, 'idx_test')).toThrow( + BuildIndexQueryError, + ); + }); + + test('Valid index expressions are unchanged', async () => { + const on: IIndexOnParams[] = [{ name: 'callsign', sort: 'ASC' }]; + + expect(buildIndexExpr('travel-sample', 'CREATE', 'idx_test', on)).toStrictEqual( + 'CREATE INDEX `idx_test` ON `travel-sample`(callsign["ASC"]) ', + ); + expect(buildIndexExpr('travel-sample', 'DROP', 'idx_test')).toStrictEqual('DROP INDEX `travel-sample`.`idx_test`'); + }); +}); diff --git a/__test__/query-utils.spec.ts b/__test__/query-utils.spec.ts index 7bded6d06..7aeae0c94 100644 --- a/__test__/query-utils.spec.ts +++ b/__test__/query-utils.spec.ts @@ -1,5 +1,5 @@ import { parseStringSelectExpr, getProjectionFields, Query, escapeReservedWords } from '../src'; -import { escapeFromClause } from '../src/query/utils'; +import { escapeFieldPath, escapeFromClause, escapeIdentifier } from '../src/query/utils'; describe('Test Query Builder Utils', () => { test('Test the conversion of select expression into an Array of selection keys', async () => { @@ -18,6 +18,26 @@ describe('Test Query Builder Utils', () => { expect(result).toStrictEqual(['address']); }); + test('Result modifier keywords are stripped when there is no alias', async () => { + expect(parseStringSelectExpr('DISTINCT a, b as c')).toStrictEqual(['a', 'c']); + expect(parseStringSelectExpr('RAW type, address')).toStrictEqual(['type', 'address']); + }); + + test('A field whose name contains a keyword is not mangled', async () => { + expect(parseStringSelectExpr('allowed, values as v')).toStrictEqual(['allowed', 'v']); + }); + + test('escapeIdentifier quotes an identifier and doubles embedded backticks', async () => { + expect(escapeIdentifier('travel-sample')).toStrictEqual('`travel-sample`'); + expect(escapeIdentifier('idx` ; DROP INDEX `other')).toStrictEqual('`idx`` ; DROP INDEX ``other`'); + }); + + test('escapeFieldPath quotes each segment so a nested path stays a path', async () => { + expect(escapeFieldPath('_type')).toStrictEqual('`_type`'); + expect(escapeFieldPath('metadata.doc_type')).toStrictEqual('`metadata`.`doc_type`'); + expect(escapeFieldPath('a`.`b')).toStrictEqual('`a```.```b`'); + }); + test('Test get Projections fields with an empty select', () => { const result = getProjectionFields('travel-sample', ''); expect(result.fields).toStrictEqual([]); @@ -57,6 +77,27 @@ describe('Test Query Builder Utils', () => { expect(expr3).toStrictEqual('`travel-sample`'); }); + test('A negative array subscript is not quoted as a dashed identifier', async () => { + expect(escapeReservedWords('numbers[-1]')).toStrictEqual('numbers[-1]'); + expect(escapeReservedWords('a.numbers[-2].b')).toStrictEqual('a.numbers[-2].b'); + }); + + test('A dashed name is quoted regardless of case', async () => { + expect(escapeReservedWords('Travel-Sample')).toStrictEqual('`Travel-Sample`'); + }); + + test('A dash-free name of any length is escaped in linear time', async () => { + // The dash-quoting pass used to be quadratic: a long name with no dash made the + // engine backtrack from every start position. At this size that took minutes. + const field = `${'0'.repeat(200000)}.x`; + + expect(escapeReservedWords(field)).toStrictEqual(field); + }); + + test('A name with several dashes is quoted as one identifier', async () => { + expect(escapeReservedWords('a-b-c')).toStrictEqual('`a-b-c`'); + }); + test('escape fromClause', () => { const escaped = escapeFromClause('travel-sample'); expect(escaped).toBe('`travel-sample`'); diff --git a/src/model/index/n1ql/ensure-n1ql-indexes.ts b/src/model/index/n1ql/ensure-n1ql-indexes.ts index 02f0876fb..829eeb184 100644 --- a/src/model/index/n1ql/ensure-n1ql-indexes.ts +++ b/src/model/index/n1ql/ensure-n1ql-indexes.ts @@ -4,6 +4,7 @@ import { ModelMetadata } from '../../interfaces/model-metadata.interface'; import { isDebugMode } from '../../../utils/is-debug-mode'; import { Ottoman } from '../../../ottoman/ottoman'; import { DEFAULT_COLLECTION } from '../../../utils/constants'; +import { escapeFieldPath, escapeIdentifier } from '../../../query/utils'; import { IndexExistsError } from 'couchbase'; /** @@ -29,10 +30,7 @@ export const ensureN1qlIndexes = async (ottoman: Ottoman, n1qlIndexes) => { ? `Ottoman${scopeName}${modelName}` : `Ottoman${scopeName}${scapedModelKey}`; if (!existingIndexesNames.includes(name)) { - const on = - collectionName !== DEFAULT_COLLECTION - ? `\`${bucketName}\`.\`${scopeName}\`.\`${collectionName}\`` - : `\`${bucketName}\``; + const on = buildKeyspace(bucketName, scopeName, collectionName); try { if (!indexesToBuild[on]) { indexesToBuild[on] = []; @@ -61,10 +59,7 @@ export const ensureN1qlIndexes = async (ottoman: Ottoman, n1qlIndexes) => { const Model = ottoman.getModel(index.modelName); const metadata = getModelMetadata(Model); const { scopeName, collectionName } = metadata; - const on = - collectionName !== DEFAULT_COLLECTION - ? `\`${bucketName}\`.\`${scopeName}\`.\`${collectionName}\`` - : `\`${bucketName}\``; + const on = buildKeyspace(bucketName, scopeName, collectionName); yield cluster .query(queryBuildIndexDefered(indexNameSanitized, fieldNames, metadata, on)) .then(() => { @@ -129,21 +124,30 @@ export const ensureN1qlIndexes = async (ottoman: Ottoman, n1qlIndexes) => { return Promise.resolve(true); }; +// Build the escaped keyspace an index is created on. +const buildKeyspace = (bucketName: string, scopeName: string, collectionName: string): string => { + return collectionName !== DEFAULT_COLLECTION + ? [bucketName, scopeName, collectionName].map(escapeIdentifier).join('.') + : escapeIdentifier(bucketName); +}; + // Create the ottoman type index, needed to make model lookups fast. const queryForIndexOttomanType = (ottomanType: string, on: string, collectionKey: string): string => { - return `CREATE INDEX \`${ottomanType}\` ON ${on}(\`${collectionKey}\`) USING GSI WITH {"defer_build": true}`; + return `CREATE INDEX ${escapeIdentifier(ottomanType)} ON ${on}(${escapeFieldPath( + collectionKey, + )}) USING GSI WITH {"defer_build": true}`; }; // Map createIndex across all individual n1ql model indexes. // concurrency: 1 is important to avoid overwhelming the server. const queryBuildIndexDefered = (indexName, fields, metadata: ModelMetadata, on: string) => { const { modelKey, modelName } = metadata; - return `CREATE INDEX \`${indexName}\` ON ${on}(${fields.join( - ',', - )}) WHERE ${modelKey}="${modelName}" USING GSI WITH {"defer_build": true}`; + return `CREATE INDEX ${escapeIdentifier(indexName)} ON ${on}(${fields.join(',')}) WHERE ${escapeFieldPath( + modelKey, + )}=${JSON.stringify(modelName)} USING GSI WITH {"defer_build": true}`; }; // All indexes were built deferred, so now kick off actual build. const queryBuildIndexes = (on, indexesName: string[]) => { - return `BUILD INDEX ON ${on}(${indexesName.map((idx) => `\`${idx}\``).join(',')}) USING GSI`; + return `BUILD INDEX ON ${on}(${indexesName.map((idx) => escapeIdentifier(idx)).join(',')}) USING GSI`; }; diff --git a/src/query/helpers/builders.ts b/src/query/helpers/builders.ts index c1c4c02c9..d95f1fa70 100644 --- a/src/query/helpers/builders.ts +++ b/src/query/helpers/builders.ts @@ -1,4 +1,4 @@ -import { BuildQueryError } from '../../exceptions/ottoman-errors'; +import { BuildIndexQueryError, BuildQueryError } from '../../exceptions/ottoman-errors'; import { CollectionInWithinExceptions, QueryGroupByParamsException, @@ -24,7 +24,7 @@ import { LogicalWhereExpr, SortType, } from '../interface/query.types'; -import { escapeFromClause, escapeReservedWords } from '../utils'; +import { escapeFieldName, escapeFromClause, escapeIdentifier, escapeReservedWords, escapeSearchExpr } from '../utils'; import { AggDict, CollectionDeepSearchOperatorDict, @@ -91,7 +91,7 @@ export const selectBuilder = ( havingExpr, )}${_buildOrderByExpr(orderBy)}${_buildLimitExpr(limit)}${_buildOffsetExpr(offset)}`; } catch (exception) { - if (exception instanceof WhereClauseException) { + if (exception instanceof WhereClauseException || exception instanceof BuildQueryError) { throw exception; } throw new SelectClauseException(); @@ -190,11 +190,35 @@ const _buildLetExpr = (letExpr?: LetExprType, clause = 'LET') => { const _buildOrderByExpr = (orderExpr: Record | undefined) => { return !!orderExpr ? ` ORDER BY ${Object.keys(orderExpr) - .map((value: string) => `${value.includes('[') ? value : escapeReservedWords(value)} ${orderExpr[value]}`) + .map( + (value: string) => + `${value.includes('[') ? value : escapeReservedWords(value)} ${_buildSortDirection(orderExpr[value])}`, + ) .join(',')}` : ''; }; +/** + * Sort directions accepted by the ORDER BY and index ON clauses. + * @ignore + * */ +const SORT_DIRECTIONS = ['ASC', 'DESC']; + +/** + * A sort direction is interpolated into the statement as bare N1QL, so it has to be + * checked against the allowed keywords rather than quoted. + * @ignore + * */ +const _buildSortDirection = (sort: SortType): string => { + const direction = String(sort).toUpperCase(); + if (!SORT_DIRECTIONS.includes(direction)) { + throw new BuildQueryError( + `The sort direction '${sort}' is not valid, use one of '${SORT_DIRECTIONS.join(`' | '`)}'`, + ); + } + return direction; +}; + /** * @ignore * */ @@ -345,19 +369,19 @@ const _buildFieldClauseExpr = (field: BuildFieldClauseExprType, ignoreCase = fal const fieldExpr = field[value]?.['$field']; if (fieldExpr && typeof fieldExpr === 'string') { - return `${escapeReservedWords(value)}=${fieldExpr}`; + return `${escapeFieldName(value)}=${fieldExpr}`; } if (!value.includes('$')) { if (typeof field[value] === 'string') { - const comparator = escapeReservedWords(value); + const comparator = escapeFieldName(value); const toCompare = stringifyValues(field[value]); return ignoreCase ? applyIgnoreCase(ignoreCase, comparator, '=', toCompare, true) : `${comparator}=${toCompare}`; } if (typeof field[value] === 'number' || typeof field[value] === 'boolean' || Array.isArray(field[value])) { - return `${escapeReservedWords(value)}=${stringifyValues(field[value])}`; + return `${escapeFieldName(value)}=${stringifyValues(field[value])}`; } } throw new QueryOperatorNotFoundException(value); @@ -393,7 +417,11 @@ const _buildComparisonClauseExpr = (fieldName: string, comparison: ComparisonWhe .map((key: string) => { const value = comparison[key]; if (value != null) { - const field = escapeReservedWords(fieldName); + // A collection operator can search for a literal, so its left-hand side is not + // necessarily a field name. + const field = CollectionDeepSearchOperatorDict.hasOwnProperty(key) + ? escapeSearchExpr(fieldName) + : escapeFieldName(fieldName); if (ComparisonEmptyOperatorDict.hasOwnProperty(key)) { return `${field} ${ComparisonEmptyOperatorDict[key]}`; } @@ -467,8 +495,16 @@ const _buildCollectionInWithinOperator = ( return `${searchExpr} ${CollectionDeepSearchOperatorDict[operator]} ${target}`; }; +/** + * Render a value as a N1QL literal. + * + * N1QL string literals accept the same escape sequences as JSON, so the output of + * `JSON.stringify` is already a valid literal. Stripping the backslashes it emits + * would let a value containing `"` terminate the literal and inject arbitrary N1QL. + * @ignore + * */ const stringifyValues = (value: unknown) => { - return JSON.stringify(value).replace(/\\/gi, ''); + return JSON.stringify(value); }; /** @@ -543,13 +579,34 @@ export const buildIndexExpr = ( usingGSI?: boolean, withExpr?: IIndexWithParams, ): string => { - if (['BUILD', 'CREATE', 'CREATE PRIMARY'].includes(type) && on) { - return `${type} INDEX \`${name}\` ON \`${collection}\`(${buildOnExpr(on)})${buildWhereExpr(where)} ${ + const _type = _buildIndexType(type); + const _name = escapeIdentifier(name); + const _collection = escapeIdentifier(collection); + if (['BUILD', 'CREATE', 'CREATE PRIMARY'].includes(_type) && on) { + return `${_type} INDEX ${_name} ON ${_collection}(${buildOnExpr(on)})${buildWhereExpr(where)} ${ usingGSI ? 'USING GSI' : '' } ${buildWithExpr(withExpr)}`; } else { - return `${type} INDEX \`${collection}\`.\`${name}\`${usingGSI ? ' USING GSI' : ''}`; + return `${_type} INDEX ${_collection}.${_name}${usingGSI ? ' USING GSI' : ''}`; + } +}; + +/** + * Index statements supported by the builder. + * @ignore + * */ +const INDEX_TYPES: IndexType[] = ['CREATE', 'BUILD', 'DROP', 'CREATE PRIMARY']; + +/** + * The statement keyword is interpolated as bare N1QL, so it is checked against the + * supported statements rather than quoted. + * @ignore + * */ +const _buildIndexType = (type: IndexType): IndexType => { + if (!INDEX_TYPES.includes(type)) { + throw new BuildIndexQueryError(`The index type '${type}' is not valid, use one of '${INDEX_TYPES.join(`' | '`)}'`); } + return type; }; /** @@ -568,7 +625,7 @@ const buildOnExpr = (on: IIndexOnParams[]) => { * */ const buildOnSortExpr = (onExpr?: IIndexOnParams) => { if (onExpr && onExpr.hasOwnProperty('sort')) { - return `["${onExpr.sort}"]`; + return `["${_buildSortDirection(onExpr.sort as SortType)}"]`; } return ''; }; diff --git a/src/query/utils.ts b/src/query/utils.ts index bbbb09246..a6f3eca25 100644 --- a/src/query/utils.ts +++ b/src/query/utils.ts @@ -1,7 +1,15 @@ +import { WhereClauseException } from './exceptions'; import { n1qlReservedWords } from './helpers'; const replaceList = ['ALL', 'DISTINCT', 'RAW', 'ELEMENT', 'VALUE']; +/** + * Matches the result-modifier keywords as whole words, so a field whose name merely + * contains one of them (`allowed`, `values`) is left alone. + * @ignore + * */ +const replaceListExpr = new RegExp(`\\b(${replaceList.join('|')})\\b`, 'g'); + export const escapeFromClause = (str: string) => { const trimStr = str.trim(); const [collection, ...rest] = trimStr.split(' '); @@ -14,6 +22,27 @@ export const escapeFromClause = (str: string) => { return `${result.join('.')}${rest.length > 0 ? ` ${rest.map((item) => item.replace(/`/g, '')).join(' ')}` : ''}`; }; +/** + * Wrap a single identifier in backticks. + * + * N1QL escapes a backtick inside a quoted identifier by doubling it, so doubling + * here keeps the identifier intact while making it impossible for the value to + * close the quoting and continue the statement. + * */ +export const escapeIdentifier = (identifier: string) => { + return `\`${String(identifier).replace(/`/g, '``')}\``; +}; + +/** + * Quote every segment of a dotted field path. + * + * Each segment is escaped on its own so that `metadata.doc_type` stays a path to a + * nested field rather than collapsing into a single identifier containing a dot. + * */ +export const escapeFieldPath = (path: string) => { + return String(path).split('.').map(escapeIdentifier).join('.'); +}; + /** * Convert select expression into an Array of selection keys * */ @@ -21,10 +50,7 @@ export const parseStringSelectExpr = (expr: string): string[] => { if (expr.indexOf(',') === -1 && expr.indexOf(' as ') === -1) { return [expr]; } - let resultExpr = expr.replace(/[()]/g, ''); - replaceList.forEach((value: string) => { - resultExpr = resultExpr.replace(new RegExp(`/[${value}]/`, 'g'), ''); - }); + const resultExpr = expr.replace(/[()]/g, '').replace(replaceListExpr, ''); return resultExpr.split(',').map((v: string) => { return extractAsValue(v); }); @@ -41,6 +67,50 @@ const extractAsValue = (expr: string): string => { return expr.trim(); }; +/** + * A single path segment: an optionally backtick-quoted name, followed by any number of + * array subscripts (`[0]`, `[-1]`, `[*]`). + * @ignore + * */ +const FIELD_SEGMENT = '`?[A-Za-z_$][A-Za-z0-9_$-]*`?(?:\\[(?:\\*|-?\\d+)\\])*'; + +/** + * A field path is one or more of those segments joined by dots. + * @ignore + * */ +const FIELD_PATH_EXPR = new RegExp(`^${FIELD_SEGMENT}(?:\\.${FIELD_SEGMENT})*$`); + +/** + * Escape a name used as a document field in a WHERE clause. + * + * `escapeReservedWords` only adds backticks for reserved words and for names holding a + * dash or subscript; every other name is emitted verbatim. That is fine for the clauses + * whose operands are deliberately N1QL expressions (GROUP BY, `$field`), but a WHERE key + * is a document field, and these keys routinely come from request data. A key such as + * `'a) OR 1=1 --'` would otherwise be interpolated as syntax, so a name that is not a + * field path is rejected instead of escaped. + * */ +export const escapeFieldName = (field: string) => { + if (!FIELD_PATH_EXPR.test(field)) { + throw new WhereClauseException( + `The field name '${field}' is not a valid field path. Expected a document field, optionally dotted and subscripted, e.g. 'address.city' or 'numbers[-1]'.`, + ); + } + return escapeReservedWords(field); +}; + +/** + * Escape the left-hand side of a collection `IN`/`WITHIN` operator. + * + * Unlike an ordinary comparison, this side may be a literal being searched for rather + * than a field — `"CORSAIR" WITHIN t` is valid. A quoted literal is re-escaped so that a + * quote inside it cannot terminate it; anything else has to be a field path. + * */ +export const escapeSearchExpr = (expr: string) => { + const literal = /^"([\s\S]*)"$/.exec(expr); + return literal ? JSON.stringify(literal[1]) : escapeFieldName(expr); +}; + /** * @ignore */ @@ -59,7 +129,17 @@ export const escapeReservedWords = (field: string) => { return value; }) .join('.'); - expr = expr.replace(/([a-z0-9]*\-[a-z0-9]*)/g, '`$&`'); + // Quote names containing a dash. + // + // The run is matched with a single character class so the match cannot backtrack. + // Requiring a dash *after* an alphanumeric run — `[a-zA-Z0-9]+-` — reads more directly + // but is quadratic: for every start position in a long dash-free name the engine + // matches to the end and then backtracks the whole way looking for the dash. + // + // Whether the run is a dashed name is then decided with plain string checks. A run + // starting with the dash is a negative array subscript, where the preceding character + // is `[` rather than part of a name, and must not be quoted into `numbers[`-1`]`. + expr = expr.replace(/[a-zA-Z0-9-]+/g, (run) => (run.includes('-') && !run.startsWith('-') ? `\`${run}\`` : run)); return expr; } return field;