Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4338e67
fix: Validate query timezone against IANA zones
ovr Aug 17, 2026
47516ce
fix(api-gateway): normalize query timezone to canonical IANA name
ovr Aug 17, 2026
6141a98
fix: apply timezone normalization across all API entry points
ovr Aug 17, 2026
5f5d081
Update packages/cubejs-api-gateway/src/query.js
ovr Aug 17, 2026
654610b
Update packages/cubejs-server-core/src/core/optionsValidate.ts
ovr Aug 17, 2026
ebec8d0
fix: fail fast on misconfigured timezone env vars
ovr Aug 17, 2026
eaf58eb
docs: drop timezone canonicalization notes from the env var reference
ovr Aug 17, 2026
062b0e7
fix: tolerate empty entries in CUBEJS_SCHEDULED_REFRESH_TIMEZONES
ovr Aug 17, 2026
12d107d
fix: use the sanitized options object returned by validateOptions
ovr Aug 17, 2026
3a5784b
refactor: rename OptsHandler.assertOptions to validateOptions
ovr Aug 17, 2026
c82de0b
refactor: rename OptsHandler.validateOptions to sanitizeOptions
ovr Aug 17, 2026
bf6d42a
refactor(shared): make canonicalTimezone strict about argument types
ovr Aug 19, 2026
c08ded8
refactor(api-gateway): drop normalizeTimezone in favour of timezoneSc…
ovr Aug 19, 2026
35f969f
feat(api-gateway): validate the whole /v1/cubesql body with a schema
ovr Aug 19, 2026
eef56da
refactor(shared): check for an empty timezone explicitly
ovr Aug 19, 2026
3393f95
refactor(shared): reject an empty timezone instead of returning null
ovr Aug 19, 2026
3b59610
docs: trim timezone comments that restate the code
ovr Aug 19, 2026
d60aa38
docs: document CUBEJS_SCHEDULED_REFRESH_TIMEZONES validation
ovr Aug 19, 2026
7005ea1
docs: drop the empty-entries note from CUBEJS_SCHEDULED_REFRESH_TIMEZ…
ovr Aug 19, 2026
79b1c43
fix(schema-compiler): canonicalize the query timezone in BaseQuery
ovr Aug 19, 2026
b4d5ef6
test(schema-compiler): drop the non-string timezone cases
ovr Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs-mintlify/reference/configuration/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1106,6 +1106,13 @@ The default [time zone][ref-time-zone] for queries.
You can set the time zone name in the [TZ Database Name][link-tzdb] format, e.g.,
`America/Los_Angeles`.

<Warning>

An invalid value fails at server startup. Only TZ Database names are accepted — fixed
UTC offsets such as `+05:00` are rejected.

</Warning>

This is the fallback. When [user time zones](/admin/time-zones) are enabled for the
account, a resolved account, personal, dashboard, or embed zone is sent with the query and
takes precedence over this value.
Expand Down Expand Up @@ -1408,6 +1415,13 @@ for][ref-config-sched-refresh-timer].
| --------------------------------------------------------- | ---------------------- | --------------------- |
| [A valid timezone from the tz database][wiki-tz-database] | N/A | N/A |

<Warning>

An invalid value fails at server startup. Only TZ Database names are accepted — fixed
UTC offsets such as `+05:00` are rejected.

</Warning>

It can be also set using the [`scheduled_refresh_time_zones` configuration
option](/reference/configuration/config#scheduled_refresh_time_zones).

Expand Down
12 changes: 9 additions & 3 deletions packages/cubejs-api-gateway/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import { SubscriptionServer, WebSocketSendMessageFn } from './ws/subscription-se
import { LocalSubscriptionStore } from './ws/local-subscription-store';
import {
getPivotQuery,
cubeSqlRequestSchema,
getQueryGranularity,
normalizeQuery,
normalizeQueryCancelPreAggregations,
Expand Down Expand Up @@ -480,7 +481,12 @@ class ApiGateway {
try {
await this.assertApiScope('data', req.context?.securityContext);

await this.sqlServer.execSql(req.body.query, res, req.context?.securityContext, req.body.cache, req.body.timezone, req.body.throwContinueWait, req.context?.requestId);
const { error, value: body } = cubeSqlRequestSchema.validate(req.body);
Comment thread
ovr marked this conversation as resolved.
if (error) {
throw new UserError(`Invalid query format: ${error.message || error.toString()}`);
}

await this.sqlServer.execSql(body.query, res, req.context?.securityContext, body.cache, body.timezone, body.throwContinueWait, req.context?.requestId);
} catch (e: any) {
// Quickfix for https://github.com/cube-js/cube/issues/10450,
// Right now, it's too complicated to fix the issue correctly, because
Expand Down Expand Up @@ -985,7 +991,7 @@ class ApiGateway {
throw new UserError('No job description provided');
}

const { error } = preAggsJobsRequestSchema.validate(query);
const { error, value } = preAggsJobsRequestSchema.validate(query);
if (error) {
throw new UserError(`Invalid Job query format: ${error.message || error.toString()}`);
}
Expand All @@ -994,7 +1000,7 @@ class ApiGateway {
case 'post':
result = await this.preAggregationsJobsPOST(
context,
<PreAggsSelector>query.selector
<PreAggsSelector>value.selector
);
if (result.length === 0) {
throw new UserError(
Expand Down
49 changes: 34 additions & 15 deletions packages/cubejs-api-gateway/src/query.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import R from 'ramda';
import moment from 'moment';
import moment from 'moment-timezone';
import Joi from 'joi';
import { getEnv } from '@cubejs-backend/shared';
import { canonicalTimezone, getEnv } from '@cubejs-backend/shared';

import { UserError } from './user-error';
import { dateParser } from './date-parser';
Expand Down Expand Up @@ -56,6 +56,18 @@ const evaluatedPatchMeasureExpression = parsedPatchMeasureExpression.keys({
});

const id = Joi.string().regex(/^[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+$/);

const cacheModeSchema = Joi.valid('stale-if-slow', 'stale-while-revalidate', 'must-revalidate', 'no-cache');

const timezoneSchema = Joi.string().custom((value, helpers) => {
const name = canonicalTimezone(value);
if (!name) {
return helpers.message({ custom: '{{#label}} must be a valid IANA time zone, got "{{#tz}}"' }, { tz: value });
}

return name;
}, 'timezone');

// It might be member name, td+granularity or member expression
const idOrMemberExpressionName = Joi.string().regex(/^[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+$|^[a-zA-Z0-9_]+$|^[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+$/);
const dimensionWithTime = Joi.string().regex(/^[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+(\.[a-zA-Z0-9_]+)?$/);
Expand Down Expand Up @@ -183,12 +195,12 @@ const querySchema = Joi.object().keys({
Joi.array().items(Joi.array().min(2).ordered(idOrMemberExpressionName, Joi.valid('asc', 'desc')))
),
segments: Joi.array().items(Joi.alternatives(id, memberExpression, parsedMemberExpression)),
timezone: Joi.string(),
timezone: timezoneSchema,
limit: Joi.number().integer().strict().min(0),
offset: Joi.number().integer().strict().min(0),
total: Joi.boolean(),
cacheMode: Joi.valid('stale-if-slow', 'stale-while-revalidate', 'must-revalidate', 'no-cache'),
cache: Joi.valid('stale-if-slow', 'stale-while-revalidate', 'must-revalidate', 'no-cache'),
cacheMode: cacheModeSchema,
cache: cacheModeSchema,
ungrouped: Joi.boolean(),
responseFormat: Joi.valid('default', 'compact', 'columnar'),
subqueryJoins: Joi.array().items(subqueryJoin),
Expand All @@ -199,6 +211,13 @@ const querySchema = Joi.object().keys({
})),
});

export const cubeSqlRequestSchema = Joi.object().keys({
query: Joi.string().required(),
timezone: timezoneSchema,
cache: cacheModeSchema,
throwContinueWait: Joi.boolean(),
});

const normalizeQueryOrder = order => {
let result = [];
const normalizeOrderItem = (k, direction) => ([k, direction]);
Expand All @@ -220,7 +239,7 @@ export const preAggsJobsRequestSchema = Joi.object({
securityContext: Joi.required(),
})
).min(1).required(),
timezones: Joi.array().items(Joi.string()).min(1).required(),
timezones: Joi.array().items(timezoneSchema).min(1).required(),
dataSources: Joi.array().items(Joi.string()),
cubes: Joi.array().items(Joi.string()),
preAggregations: Joi.array().items(Joi.string()),
Expand Down Expand Up @@ -416,7 +435,7 @@ function normalizeQueryCacheMode(query, cacheMode) {
const normalizeQuery = (query, persistent, cacheMode) => {
query = normalizeQueryCacheMode(query, cacheMode);
query.timezone = query.timezone || getEnv('defaultTimezone');
Comment thread
claude[bot] marked this conversation as resolved.
const { error } = querySchema.validate(query);
const { error, value } = querySchema.validate(query);
if (error) {
throw new UserError(`Invalid query format: ${error.message || error.toString()}`);
}
Expand All @@ -434,7 +453,7 @@ const normalizeQuery = (query, persistent, cacheMode) => {
dimension: d.split('.').slice(0, 2).join('.'),
granularity: d.split('.')[2]
}));
const timezone = query.timezone || 'UTC';
const timezone = value.timezone || 'UTC';

const def = getEnv('dbQueryDefaultLimit') <= getEnv('dbQueryLimit')
? getEnv('dbQueryDefaultLimit')
Expand Down Expand Up @@ -497,8 +516,8 @@ const remapToQueryAdapterFormat = (query) => (query ? {
const queryPreAggregationsSchema = Joi.object().keys({
expand: Joi.array().items(Joi.string()),
metadata: Joi.object(),
timezone: Joi.string(),
timezones: Joi.array().items(Joi.string()),
timezone: timezoneSchema,
timezones: Joi.array().items(timezoneSchema),
Comment thread
claude[bot] marked this conversation as resolved.
preAggregations: Joi.array().items(Joi.object().keys({
id: Joi.string().required(),
cacheOnly: Joi.boolean(),
Expand All @@ -509,22 +528,22 @@ const queryPreAggregationsSchema = Joi.object().keys({
});

const normalizeQueryPreAggregations = (query, defaultValues) => {
const { error } = queryPreAggregationsSchema.validate(query);
const { error, value } = queryPreAggregationsSchema.validate(query);
if (error) {
throw new UserError(`Invalid query format: ${error.message || error.toString()}`);
}

return {
metadata: query.metadata,
timezones: query.timezones || (query.timezone && [query.timezone]) || defaultValues?.timezones || ['UTC'],
timezones: value.timezones || (value.timezone && [value.timezone]) || defaultValues?.timezones || ['UTC'],
preAggregations: query.preAggregations,
expand: query.expand
};
};

const queryPreAggregationPreviewSchema = Joi.object().keys({
preAggregationId: Joi.string().required(),
timezone: Joi.string().required(),
timezone: timezoneSchema.required(),
versionEntry: Joi.object().required().keys({
content_version: Joi.string(),
last_updated_at: Joi.number(),
Expand All @@ -536,12 +555,12 @@ const queryPreAggregationPreviewSchema = Joi.object().keys({
});

const normalizeQueryPreAggregationPreview = (query) => {
const { error } = queryPreAggregationPreviewSchema.validate(query);
const { error, value } = queryPreAggregationPreviewSchema.validate(query);
if (error) {
throw new UserError(`Invalid query format: ${error.message || error.toString()}`);
}

return query;
return { ...query, timezone: value.timezone };
};

const queryCancelPreAggregationPreviewSchema = Joi.object().keys({
Expand Down
144 changes: 143 additions & 1 deletion packages/cubejs-api-gateway/test/normalize-query.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { normalizeQuery } from '../src/query';
import {
cubeSqlRequestSchema,
normalizeQuery,
normalizeQueryPreAggregations,
normalizeQueryPreAggregationPreview,
} from '../src/query';

const baseQuery = {
measures: ['Foo.count'],
Expand All @@ -19,3 +24,140 @@ describe('responseFormat validation', () => {
expect(() => normalizeQuery({ ...baseQuery, responseFormat: 'arrow' }, false)).toThrow(/Invalid query format/);
});
});

describe('timezone validation', () => {
test.each(['UTC', 'America/New_York', 'Europe/Berlin', 'Asia/Tokyo'])(
'accepts valid IANA timezone %s',
(tz) => {
const result = normalizeQuery({ ...baseQuery, timezone: tz }, false);
expect(result.timezone).toBe(tz);
}
);

test.each([
['america/new_york', 'America/New_York'],
['AMERICA/NEW_YORK', 'America/New_York'],
['utc', 'UTC'],
['uTc', 'UTC'],
])('accepts timezone case-insensitively and normalizes it: %s -> %s', (tz, expected) => {
const result = normalizeQuery({ ...baseQuery, timezone: tz }, false);
expect(result.timezone).toBe(expected);
});

test.each([
'Not/AZone',
'+05:00',
'foo/bar',
])('rejects invalid timezone %j', (tz) => {
expect(() => normalizeQuery({ ...baseQuery, timezone: tz }, false)).toThrow(/Invalid query format/);
});

describe('default timezone fallback', () => {
afterEach(() => {
delete process.env.CUBEJS_DEFAULT_TIMEZONE;
});

test('falls back to UTC when CUBEJS_DEFAULT_TIMEZONE is unset', () => {
delete process.env.CUBEJS_DEFAULT_TIMEZONE;

const { timezone, ...queryWithoutTimezone } = baseQuery;
const result = normalizeQuery(queryWithoutTimezone, false);
expect(result.timezone).toBe('UTC');
});

test('uses the canonicalized CUBEJS_DEFAULT_TIMEZONE when set', () => {
process.env.CUBEJS_DEFAULT_TIMEZONE = 'america/new_york';

const { timezone, ...queryWithoutTimezone } = baseQuery;
const result = normalizeQuery(queryWithoutTimezone, false);
expect(result.timezone).toBe('America/New_York');
});
});
});

describe('normalizeQueryPreAggregations timezone handling', () => {
test('normalizes timezone to canonical IANA name', () => {
const result = normalizeQueryPreAggregations({ timezone: 'america/new_york' }, undefined);
expect(result.timezones).toEqual(['America/New_York']);
});

test('normalizes timezones array to canonical IANA names', () => {
const result = normalizeQueryPreAggregations({ timezones: ['utc', 'europe/berlin'] }, undefined);
expect(result.timezones).toEqual(['UTC', 'Europe/Berlin']);
});

test('rejects invalid timezone', () => {
expect(() => normalizeQueryPreAggregations({ timezones: ['Not/AZone'] }, undefined)).toThrow(/Invalid query format/);
});
});

describe('normalizeQueryPreAggregationPreview timezone handling', () => {
const previewQuery = {
preAggregationId: 'cube.preAgg',
versionEntry: { content_version: 'a', structure_version: 'b' },
};

test('normalizes timezone to canonical IANA name', () => {
const result = normalizeQueryPreAggregationPreview({ ...previewQuery, timezone: 'america/new_york' });
expect(result.timezone).toBe('America/New_York');
});

test('rejects invalid timezone', () => {
expect(() => normalizeQueryPreAggregationPreview({ ...previewQuery, timezone: 'Not/AZone' })).toThrow(/Invalid query format/);
});
});

describe('cubeSqlRequestSchema', () => {
const baseBody = { query: 'SELECT 1' };

test('accepts a body with only the query', () => {
const { error, value } = cubeSqlRequestSchema.validate(baseBody);
expect(error).toBeUndefined();
expect(value).toEqual(baseBody);
});

test('accepts every supported field', () => {
const { error, value } = cubeSqlRequestSchema.validate({
...baseBody,
timezone: 'America/Los_Angeles',
cache: 'stale-while-revalidate',
throwContinueWait: true,
});
expect(error).toBeUndefined();
expect(value.cache).toBe('stale-while-revalidate');
expect(value.throwContinueWait).toBe(true);
});

test('requires the query', () => {
expect(cubeSqlRequestSchema.validate({}).error?.message).toMatch(/"query" is required/);
});

test('rejects an unknown field', () => {
expect(cubeSqlRequestSchema.validate({ ...baseBody, nope: 1 }).error).toBeDefined();
});

test('rejects an unknown cache mode', () => {
expect(cubeSqlRequestSchema.validate({ ...baseBody, cache: 'sometimes' }).error).toBeDefined();
});

test.each([
['america/new_york', 'America/New_York'],
['uTc', 'UTC'],
])('normalizes timezone %j -> %j', (tz, expected) => {
const { error, value } = cubeSqlRequestSchema.validate({ ...baseBody, timezone: tz });
expect(error).toBeUndefined();
expect(value.timezone).toBe(expected);
});

test.each([
'Not/AZone',
'foo/bar',
])('rejects invalid timezone %j', (tz) => {
expect(cubeSqlRequestSchema.validate({ ...baseBody, timezone: tz }).error?.message)
.toMatch(/valid IANA time zone/);
});

test.each([null, '', 123, true])('rejects timezone %j', (tz) => {
expect(cubeSqlRequestSchema.validate({ ...baseBody, timezone: tz }).error).toBeDefined();
});
});
Loading
Loading