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
@@ -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,8 @@
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
export type GroupByDefinition = {
columnNameWithQuotes: string;
expression: string;
alias: string;
dateGranularity?: ObjectRecordGroupByDateGranularity;
};
@@ -0,0 +1,31 @@
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { type FirstDayOfTheWeek } from 'twenty-shared/utils';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
export type GroupByRegularField = {
fieldMetadata: FlatFieldMetadata;
subFieldName?: string;
};
export type GroupByDateField = {
fieldMetadata: FlatFieldMetadata;
subFieldName?: string;
dateGranularity: ObjectRecordGroupByDateGranularity;
weekStartDay?: FirstDayOfTheWeek;
timeZone?: string;
};
export type GroupByRelationField = {
fieldMetadata: FlatFieldMetadata;
nestedFieldMetadata: FlatFieldMetadata;
nestedSubFieldName?: string;
dateGranularity?: ObjectRecordGroupByDateGranularity;
weekStartDay?: FirstDayOfTheWeek;
timeZone?: string;
};
export type GroupByField =
| GroupByRegularField
| GroupByDateField
| GroupByRelationField;
@@ -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 = ({
@@ -0,0 +1,108 @@
import { isNonEmptyString } from '@sniptt/guards';
import {
GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE,
IANA_TIME_ZONES,
} from 'twenty-shared/constants';
import {
FieldMetadataType,
ObjectRecordGroupByDateGranularity,
} from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import {
CommonQueryRunnerException,
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/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,
columnNameWithQuotes,
}: {
groupByField: GroupByField;
columnNameWithQuotes: string;
}) => {
if (
!(isGroupByDateField(groupByField) || isGroupByRelationField(groupByField))
) {
return columnNameWithQuotes;
}
const dateGranularity = groupByField.dateGranularity;
if (!isDefined(dateGranularity)) {
return columnNameWithQuotes;
}
const shouldUseTimeZone =
GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE.includes(
dateGranularity,
) && groupByField.fieldMetadata.type === FieldMetadataType.DATE_TIME;
const timeZoneIsNotProvided = !isNonEmptyString(groupByField.timeZone);
if (shouldUseTimeZone && timeZoneIsNotProvided) {
throw new CommonQueryRunnerException(
'Time zone should be specified for a group by date on Day, Week, Month, Quarter or Year',
CommonQueryRunnerExceptionCode.MISSING_TIMEZONE_FOR_DATE_GROUP_BY,
{ userFriendlyMessage: STANDARD_ERROR_MESSAGE },
);
}
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}'`
: '';
const timeZoneAsToCharParameter = shouldUseTimeZone
? ` AT TIME ZONE '${groupByField.timeZone}'`
: '';
switch (dateGranularity) {
case ObjectRecordGroupByDateGranularity.NONE:
return columnNameWithQuotes;
case ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK:
return `TRIM(TO_CHAR(${columnNameWithQuotes}, 'TMDay'))`;
case ObjectRecordGroupByDateGranularity.MONTH_OF_THE_YEAR:
return `TRIM(TO_CHAR(${columnNameWithQuotes}, 'TMMonth'))`;
case ObjectRecordGroupByDateGranularity.QUARTER_OF_THE_YEAR:
return `TRIM(TO_CHAR(${columnNameWithQuotes}, '"Q"Q'))`;
case ObjectRecordGroupByDateGranularity.WEEK: {
const weekStartDay = groupByField.weekStartDay;
let shiftedExpression = `DATE_TRUNC('week', ${columnNameWithQuotes}${timeZoneAsDateTruncParameter})`;
if (isDefined(weekStartDay)) {
if (weekStartDay === 'SUNDAY') {
shiftedExpression = `(DATE_TRUNC('week', ${columnNameWithQuotes} + INTERVAL '1 day'${timeZoneAsDateTruncParameter}) - INTERVAL '1 day')`;
} else if (weekStartDay === 'SATURDAY') {
shiftedExpression = `(DATE_TRUNC('week', ${columnNameWithQuotes} + INTERVAL '2 days'${timeZoneAsDateTruncParameter}) - INTERVAL '2 days')`;
}
}
return `TO_CHAR(${shiftedExpression}${timeZoneAsToCharParameter}, 'YYYY-MM-DD')`;
}
case ObjectRecordGroupByDateGranularity.DAY:
case ObjectRecordGroupByDateGranularity.MONTH:
case ObjectRecordGroupByDateGranularity.QUARTER:
case ObjectRecordGroupByDateGranularity.YEAR:
return `TO_CHAR(DATE_TRUNC('${dateGranularity}', ${columnNameWithQuotes}${timeZoneAsDateTruncParameter})${timeZoneAsToCharParameter}, 'YYYY-MM-DD')`;
default:
assertUnreachable(dateGranularity);
}
};
@@ -0,0 +1,82 @@
import {
FirstDayOfTheWeek,
ObjectRecordGroupByDateGranularity,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
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',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
] as const;
const getDayOfWeekOrderExpression = (
groupByExpression: string,
weekStartDay?: FirstDayOfTheWeek,
): string => {
const startDay = weekStartDay ?? FirstDayOfTheWeek.MONDAY;
const startIndex =
startDay === FirstDayOfTheWeek.SUNDAY
? 6
: startDay === FirstDayOfTheWeek.SATURDAY
? 5
: 0;
const orderedDays = [
...DAYS_OF_WEEK.slice(startIndex),
...DAYS_OF_WEEK.slice(0, startIndex),
];
const caseConditions = orderedDays
.map((day, index) => `WHEN '${day}' THEN ${index + 1}`)
.join(' ');
return `CASE ${groupByExpression} ${caseConditions} END`;
};
export const getGroupByOrderExpression = ({
groupByField,
columnNameWithQuotes,
}: {
groupByField: GroupByField;
columnNameWithQuotes: string;
}): string => {
if (
!(isGroupByDateField(groupByField) || isGroupByRelationField(groupByField))
) {
return getGroupByExpression({ groupByField, columnNameWithQuotes });
}
const dateGranularity = groupByField.dateGranularity;
if (!isDefined(dateGranularity)) {
return getGroupByExpression({ groupByField, columnNameWithQuotes });
}
const groupByExpression = getGroupByExpression({
groupByField,
columnNameWithQuotes,
});
switch (dateGranularity) {
case ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK:
return getDayOfWeekOrderExpression(
groupByExpression,
groupByField.weekStartDay,
);
case ObjectRecordGroupByDateGranularity.MONTH_OF_THE_YEAR:
return `CASE ${groupByExpression} WHEN 'January' THEN 1 WHEN 'February' THEN 2 WHEN 'March' THEN 3 WHEN 'April' THEN 4 WHEN 'May' THEN 5 WHEN 'June' THEN 6 WHEN 'July' THEN 7 WHEN 'August' THEN 8 WHEN 'September' THEN 9 WHEN 'October' THEN 10 WHEN 'November' THEN 11 WHEN 'December' THEN 12 END`;
default:
return groupByExpression;
}
};
@@ -0,0 +1,13 @@
import {
type GroupByDateField,
type GroupByField,
} from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
export const isGroupByDateField = (
groupByField: GroupByField,
): groupByField is GroupByDateField => {
return (
'dateGranularity' in groupByField &&
!('nestedFieldMetadata' in groupByField)
);
};
@@ -0,0 +1,10 @@
import {
type GroupByField,
type GroupByRelationField,
} from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
export const isGroupByRelationField = (
groupByField: GroupByField,
): groupByField is GroupByRelationField => {
return 'nestedFieldMetadata' in groupByField;
};
@@ -0,0 +1,9 @@
const removeQuotes = (string: string): string => {
return string.replace(/["']/g, '');
};
export const formatColumnNameAsAlias = (
columnNameWithQuotes: string,
): string => {
return removeQuotes(columnNameWithQuotes).replace(/\./g, '_');
};