Validate timezone input in group-by date queries (#17777)

## Summary

- Validates that the timezone parameter in group-by date expressions is
a recognized IANA timezone
- Adds SQL string literal escaping as a defense-in-depth measure for the
timezone value interpolated into SQL expressions
- Moves the `IANA_TIME_ZONES` constant to `twenty-shared` so it can be
reused across frontend and server packages
- Adds `INVALID_TIMEZONE` error code mapped to 400 Bad Request in both
GraphQL and REST API exception handlers

## Test plan

- [x] Unit tests for `validateIanaTimeZone` (valid IANA zones, fixed
offsets, rejects invalid strings)
- [x] Unit tests for `escapeSqlStringLiteral`
- [x] Integration tests for `getGroupByExpression` covering timezone
validation and granularity handling


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-02-08 17:06:19 +01:00
committed by GitHub
parent cc86b9acae
commit c44d61f324
23 changed files with 181 additions and 33 deletions
@@ -1,4 +1,4 @@
import { IANA_TIME_ZONES } from '@/localization/constants/IanaTimeZones';
import { IANA_TIME_ZONES } from 'twenty-shared/constants';
import { formatTimeZoneLabel } from '@/localization/utils/formatTimeZoneLabel';
import { type SelectOption } from 'twenty-ui/input';
@@ -36,10 +36,10 @@ import {
} from 'src/engine/api/common/types/common-query-args.type';
import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type';
import { GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { GroupByDefinition } from 'src/engine/api/common/common-query-runners/types/group-by-definition.type';
import { GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util';
import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util';
import { isGroupByRelationField } from 'src/engine/api/common/common-query-runners/utils/is-group-by-relation-field.util';
import { parseGroupByArgs } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util';
import { GroupByWithRecordsService } from 'src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service';
import { getGroupLimit } from 'src/engine/api/graphql/graphql-query-runner/group-by/utils/get-group-limit.util';
@@ -18,6 +18,7 @@ export enum CommonQueryRunnerExceptionCode {
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
TOO_COMPLEX_QUERY = 'TOO_COMPLEX_QUERY',
MISSING_TIMEZONE_FOR_DATE_GROUP_BY = 'MISSING_TIMEZONE_FOR_DATE_GROUP_BY',
INVALID_TIMEZONE = 'INVALID_TIMEZONE',
}
export class CommonQueryRunnerException extends CustomException<CommonQueryRunnerExceptionCode> {
@@ -0,0 +1,129 @@
import {
FieldMetadataType,
ObjectRecordGroupByDateGranularity,
} from 'twenty-shared/types';
import { type GroupByDateField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import { getGroupByExpression } from 'src/engine/api/common/common-query-runners/utils/get-group-by-expression.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
const buildDateTimeFieldMetadata = (): FlatFieldMetadata => {
return {
type: FieldMetadataType.DATE_TIME,
name: 'createdAt',
} as FlatFieldMetadata;
};
const buildGroupByDateField = (
overrides: Partial<GroupByDateField> = {},
): GroupByDateField => ({
fieldMetadata: buildDateTimeFieldMetadata(),
dateGranularity: ObjectRecordGroupByDateGranularity.DAY,
timeZone: 'America/New_York',
...overrides,
});
describe('getGroupByExpression', () => {
const columnNameWithQuotes = '"company"."createdAt"';
describe('timezone validation', () => {
it('should accept valid IANA timezones', () => {
const groupByField = buildGroupByDateField({
timeZone: 'America/New_York',
});
const result = getGroupByExpression({
groupByField,
columnNameWithQuotes,
});
expect(result).toContain("'America/New_York'");
});
it('should accept UTC timezone', () => {
const groupByField = buildGroupByDateField({ timeZone: 'UTC' });
const result = getGroupByExpression({
groupByField,
columnNameWithQuotes,
});
expect(result).toContain("'UTC'");
});
it('should reject SQL injection in timezone field', () => {
const groupByField = buildGroupByDateField({
timeZone: "UTC'; DROP TABLE users; --",
});
expect(() =>
getGroupByExpression({ groupByField, columnNameWithQuotes }),
).toThrow();
});
it('should reject UNION-based injection in timezone', () => {
const groupByField = buildGroupByDateField({
timeZone: "UTC') UNION SELECT * FROM pg_shadow --",
});
expect(() =>
getGroupByExpression({ groupByField, columnNameWithQuotes }),
).toThrow();
});
it('should reject arbitrary strings as timezone', () => {
const groupByField = buildGroupByDateField({
timeZone: 'not_a_real_timezone',
});
expect(() =>
getGroupByExpression({ groupByField, columnNameWithQuotes }),
).toThrow();
});
});
describe('missing timezone handling', () => {
it('should throw when timezone is required but not provided', () => {
const groupByField = buildGroupByDateField({
timeZone: undefined,
});
expect(() =>
getGroupByExpression({ groupByField, columnNameWithQuotes }),
).toThrow(
'Time zone should be specified for a group by date on Day, Week, Month, Quarter or Year',
);
});
});
describe('granularity without timezone', () => {
it('should return column directly for NONE granularity', () => {
const groupByField = buildGroupByDateField({
dateGranularity: ObjectRecordGroupByDateGranularity.NONE,
timeZone: undefined,
});
const result = getGroupByExpression({
groupByField,
columnNameWithQuotes,
});
expect(result).toBe(columnNameWithQuotes);
});
it('should handle DAY_OF_THE_WEEK without timezone', () => {
const groupByField = buildGroupByDateField({
dateGranularity: ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK,
timeZone: undefined,
});
const result = getGroupByExpression({
groupByField,
columnNameWithQuotes,
});
expect(result).toContain('TMDay');
expect(result).not.toContain('AT TIME ZONE');
});
});
});
@@ -28,6 +28,7 @@ export const commonQueryRunnerToGraphqlApiExceptionHandler = (
case CommonQueryRunnerExceptionCode.BAD_REQUEST:
case CommonQueryRunnerExceptionCode.TOO_COMPLEX_QUERY:
case CommonQueryRunnerExceptionCode.MISSING_TIMEZONE_FOR_DATE_GROUP_BY:
case CommonQueryRunnerExceptionCode.INVALID_TIMEZONE:
throw new UserInputError(error);
case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT:
throw new AuthenticationError(error);
@@ -27,6 +27,7 @@ export const commonQueryRunnerToRestApiExceptionHandler = (
case CommonQueryRunnerExceptionCode.BAD_REQUEST:
case CommonQueryRunnerExceptionCode.TOO_COMPLEX_QUERY:
case CommonQueryRunnerExceptionCode.MISSING_TIMEZONE_FOR_DATE_GROUP_BY:
case CommonQueryRunnerExceptionCode.INVALID_TIMEZONE:
throw new BadRequestException(error.message);
case CommonQueryRunnerExceptionCode.RECORD_NOT_FOUND:
throw new NotFoundException('Record not found');
@@ -1,9 +1,9 @@
import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type';
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util';
import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util';
import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util';
import { formatColumnNameAsAlias } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/remove-quote.util';
import { type GroupByDefinition } from 'src/engine/api/common/common-query-runners/types/group-by-definition.type';
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import { getGroupByExpression } from 'src/engine/api/common/common-query-runners/utils/get-group-by-expression.util';
import { isGroupByDateField } from 'src/engine/api/common/common-query-runners/utils/is-group-by-date-field.util';
import { isGroupByRelationField } from 'src/engine/api/common/common-query-runners/utils/is-group-by-relation-field.util';
import { formatColumnNameAsAlias } from 'src/engine/api/common/common-query-runners/utils/remove-quote.util';
import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util';
export const getGroupByDefinitions = ({
@@ -1,5 +1,8 @@
import { isNonEmptyString } from '@sniptt/guards';
import { GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE } from 'twenty-shared/constants';
import {
GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE,
IANA_TIME_ZONES,
} from 'twenty-shared/constants';
import {
FieldMetadataType,
ObjectRecordGroupByDateGranularity,
@@ -11,9 +14,11 @@ import {
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util';
import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util';
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import { isGroupByDateField } from 'src/engine/api/common/common-query-runners/utils/is-group-by-date-field.util';
import { isGroupByRelationField } from 'src/engine/api/common/common-query-runners/utils/is-group-by-relation-field.util';
const VALID_IANA_TIMEZONES = new Set(IANA_TIME_ZONES);
export const getGroupByExpression = ({
groupByField,
@@ -49,6 +54,18 @@ export const getGroupByExpression = ({
);
}
if (
shouldUseTimeZone &&
!timeZoneIsNotProvided &&
!VALID_IANA_TIMEZONES.has(groupByField.timeZone!)
) {
throw new CommonQueryRunnerException(
`Invalid timezone: ${groupByField.timeZone}`,
CommonQueryRunnerExceptionCode.INVALID_TIMEZONE,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
const timeZoneAsDateTruncParameter = shouldUseTimeZone
? `, '${groupByField.timeZone}'`
: '';
@@ -4,10 +4,10 @@ import {
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util';
import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util';
import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util';
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import { getGroupByExpression } from 'src/engine/api/common/common-query-runners/utils/get-group-by-expression.util';
import { isGroupByDateField } from 'src/engine/api/common/common-query-runners/utils/is-group-by-date-field.util';
import { isGroupByRelationField } from 'src/engine/api/common/common-query-runners/utils/is-group-by-relation-field.util';
const DAYS_OF_WEEK = [
'Monday',
@@ -1,7 +1,7 @@
import {
type GroupByDateField,
type GroupByField,
} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
} from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
export const isGroupByDateField = (
groupByField: GroupByField,
@@ -1,7 +1,7 @@
import {
type GroupByField,
type GroupByRelationField,
} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
} from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
export const isGroupByRelationField = (
groupByField: GroupByField,
@@ -20,8 +20,8 @@ import {
type GroupByDateField,
type GroupByField,
type GroupByRegularField,
} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { getGroupByOrderExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-order-expression.util';
} from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import { getGroupByOrderExpression } from 'src/engine/api/common/common-query-runners/utils/get-group-by-order-expression.util';
import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
import {
type AggregationField,
@@ -5,8 +5,8 @@ import { isDefined } from 'twenty-shared/utils';
import {
type GroupByField,
type GroupByRelationField,
} from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util';
} from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import { isGroupByRelationField } from 'src/engine/api/common/common-query-runners/utils/is-group-by-relation-field.util';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
@@ -18,7 +18,7 @@ import {
GraphqlQuerySelectedFieldsParser,
type GraphqlQuerySelectedFieldsResult,
} from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser';
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@@ -6,7 +6,7 @@ import {
import { isDefined } from 'twenty-shared/utils';
import { type CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type';
import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type';
import { type GroupByDefinition } from 'src/engine/api/common/common-query-runners/types/group-by-definition.type';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@@ -3,7 +3,7 @@ import { isDefined } from 'twenty-shared/utils';
import { type GroupByResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import { isGroupByDateFieldDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field-definition.util';
import { parseGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-relation-field.util';
import { validateSingleKeyForGroupByOrThrow } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/validate-single-key-for-group-by-or-throw.util';
@@ -8,7 +8,7 @@ import {
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { type CompositeFieldGroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/composite-field-group-by-definition.type';
import { type DateFieldGroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/date-field-group-by-definition.type';
import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types';
import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import { isGroupByDateFieldDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field-definition.util';
import { validateSingleKeyForGroupByOrThrow } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/validate-single-key-for-group-by-or-throw.util';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@@ -14,7 +14,7 @@ import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/co
import { type CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type';
import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type';
import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.type';
import { type GroupByDefinition } from 'src/engine/api/common/common-query-runners/types/group-by-definition.type';
import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util';
import { getGroupLimit } from 'src/engine/api/graphql/graphql-query-runner/group-by/utils/get-group-limit.util';
import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper';
@@ -1,7 +1,5 @@
/**
* Standard IANA time zones.
* @see https://www.iana.org/time-zones
*/
// Standard IANA time zones
// https://www.iana.org/time-zones
export const IANA_TIME_ZONES = [
'Africa/Abidjan',
'Africa/Accra',
@@ -26,6 +26,7 @@ export { MAX_OPTIONS_TO_DISPLAY } from './FieldMetadataMaxOptionsToDisplay';
export { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from './FieldRestrictedAdditionalPermissionsRequired';
export { FILES_FIELD_MAX_NUMBER_OF_VALUES } from './FilesFieldMaxNumberOfValues';
export { GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE } from './GroupByDateGranularityThatRequireTimeZone';
export { IANA_TIME_ZONES } from './IanaTimeZones';
export { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from './LabelIdentifierFieldMetadataTypes';
export { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from './MultiItemFieldDefaultMaxValues';
export { MULTI_ITEM_FIELD_MIN_MAX_VALUES } from './MultiItemFieldMinMaxValues';