MDL Shield

Timezone Clock

block_timezoneclock

Print Report
Plugin Information

A Moodle block that displays clocks for multiple timezones and provides an interactive, AJAX-driven timezone/timestamp converter. It can also render a clock in the current timezone on user profile pages.

Privacy API
Unit Tests
Behat Tests
Reviewed:2026-09-13
23 files·2,947 lines
Grade Justification

This is a clean, security-conscious block plugin. The interactive converter is implemented as a core_form\dynamic_form, so authentication, sesskey validation, and context validation are handled by core's web-services layer (call_external_function enforces isloggedin() + require_sesskey(), and validate_context() runs require_login() on the block's course). All user- and admin-influenced data is neutralised before output: the block title goes through format_string(), the date format string is constrained by a server-side regex rule, separators are HTML-escaped, and every timezone value passes through PARAM_TIMEZONE/core_date::normalise_timezone() (which falls back to a valid zone), with Mustache auto-escaping on top. There is no direct database or filesystem access, no use of superglobals, no deprecated API usage, and the MOODLE_INTERNAL guards and Privacy null_provider are all appropriate. The only substantive finding is a low-severity information disclosure: the profile clock reveals a user's configured timezone to anyone who can view their profile, and the shipped default for profileclocktype makes this visible rather than hidden. Its blast radius is limited (timezone is low-sensitivity, disclosure is gated by profile-view access, and an administrator can disable it). A single informational note about non-localised date rendering is the only other item. No medium, high, or critical issues were identified.

AI Summary

block_timezoneclock is a well-engineered block that shows multiple timezone clocks (digital or analogue) and offers an AJAX timezone/timestamp converter, plus an optional clock on user profile pages.

Security posture is strong. The converter is a core_form\dynamic_form, so the sensitive concerns (login, sesskey, context validation) are delegated to core and correctly enforced. Output handling is careful throughout:

  • The configurable block title is rendered via format_string().
  • The user-supplied date format is limited by a server-side regex rule to date tokens and a few separators.
  • Free-form separators are additionally passed through htmlspecialchars().
  • All timezone identifiers are normalised with core_date::normalise_timezone() and typed as PARAM_TIMEZONE, so arbitrary input can never reach output un-normalised.
  • Templates rely on Mustache auto-escaping, and the few triple-brace values are pre-escaped or numeric.

No SQL, filesystem, superglobal, or code-execution concerns exist, and there are no deprecated core calls.

Findings. Only two minor items were identified:

  1. Low — the profile-page clock discloses a user's timezone to other profile viewers, and the shipped default makes it visible.
  2. Info — month/weekday names are always rendered in English (client and server), so dates are not localised.

The bundled third-party library (Choices.js 11.2.1) is correctly declared in thirdpartylibs.xml.

Findings

securityLow
Profile clock discloses a user's timezone to other profile viewers
Exploitable by:
studentteacher

The block_timezoneclock_myprofile_navigation() callback adds a "Clock" node to every user profile it is invoked for, rendering a clock in that profile owner's configured timezone. The callback receives $iscurrentuser but ignores it, so the clock is shown to other users viewing the profile, not just to the owner.

For the analogue profile clock (the shipped default), main::export_for_template() sets the displayed label to the raw timezone identifier (e.g. Asia/Kolkata), so a viewer learns the target's timezone directly. Even for other clock types, the live time shown reveals the user's UTC offset.

Visibility is governed by the admin setting profileclocktype. Its shipped default is analog (a visible clock); the block is only suppressed when the setting is empty or explicitly set to hidden (check_hiddenonprofile()). There is no per-user opt-out.

A user's timezone is not part of the profile information Moodle exposes to other users by default, so enabling this block widens what is disclosed about every account.

Risk Assessment

Low risk. The disclosed datum — a user's timezone — is low sensitivity (it reveals an approximate region/working hours, not credentials or private content), and access is gated by Moodle's existing profile-view controls, so this is not an anonymous data leak. Mitigating factors: an administrator can set profileclocktype to hidden to switch the feature off entirely, and the code fails safe when the setting is empty. The reason it still warrants a finding is that the shipped default is visible, there is no per-user control, and the feature exposes an account attribute that core does not otherwise surface to other users. Any authenticated user able to view the target's profile can observe it; no special capability is required.

Context

block_timezoneclock_myprofile_navigation() is a core myprofile callback invoked by \core_user\output\myprofile\tree whenever a user profile page is rendered. Who can see a given profile is controlled by core (course membership, forceloginforprofiles, profile-visibility settings), so this is generally reachable by authenticated users who share context with the target rather than by the anonymous public. The block reads $user->timezone (the profile owner) via core_date::get_user_timezone() and renders the profileclock template.

Proof of Concept

On a site with the block installed and profileclocktype set to a visible value (its default is analog):

  1. Log in as any user who can view another account's profile (e.g. a student who shares a course with the target).
  2. Open the target's profile, e.g. /user/profile.php?id=<victimid> or /user/view.php?id=<victimid>&course=<courseid>.
  3. The "Clock" profile category shows a clock labelled with the victim's timezone (e.g. Asia/Kolkata) and the current time in that zone.
lib.php:51Source link unavailable — plugin was reviewed from zip without a matching git ref
Identified Code
function block_timezoneclock_myprofile_navigation(\core_user\output\myprofile\tree $tree, $user, $iscurrentuser, $course) {
    global $PAGE, $OUTPUT;

    /** @var \block_timezoneclock $block */
    $block = block_instance('timezoneclock', null, $PAGE);
    if ($block->check_hiddenonprofile()) {
        return;
    }
    $block->set_showingonprofile(true);
Suggested Fix

Option A — restrict to the profile owner. If the clock is only meant for the user themselves, return early for other viewers:

if (!$iscurrentuser) {
    return;
}

Option B — keep cross-user visibility but make it opt-in. Change the shipped default of profileclocktype to hidden (see settings.php) and document in the setting description that enabling it reveals each user's timezone to anyone who can view their profile.

classes/output/main.php:85Source link unavailable — plugin was reviewed from zip without a matching git ref
Identified Code
        if ($this->block->check_showingonprofile()) {
            $context->information['user']['timezone'] = $usertimezone;
            return $context;
        }
Suggested Fix

When the clock is shown to viewers other than the owner, consider labelling it with a generic string (as is done for the in-block clock, which uses get_string('tzinformation:userlabel', ...)) instead of the raw timezone identifier, so the exact zone name is not spelled out. Note this only reduces, not removes, the disclosure, since the displayed time still reveals the offset.

settings.php:42Source link unavailable — plugin was reviewed from zip without a matching git ref
Identified Code
    $setting = new admin_setting_configselect(
        $name,
        $title,
        $description,
        block_timezoneclock\util::TYPEANALOG,
        array_merge(
            ['hidden' => get_string('hidden', 'block_timezoneclock')],
            block_timezoneclock\util::get_clocktypes()
        )
    );
Suggested Fix

Consider defaulting the profile clock to hidden so the disclosure is opt-in:

    $setting = new admin_setting_configselect(
        $name,
        $title,
        $description,
        'hidden',
        array_merge(
            ['hidden' => get_string('hidden', 'block_timezoneclock')],
            block_timezoneclock\util::get_clocktypes()
        )
    );
best practiceInfo
Date and time component names are always rendered in English

The client-side module hardcodes the formatting locale to English via getLangCode = () => 'en', which is then fed to every Intl.DateTimeFormat call. On the server, block_timezoneclock::dateinfo() builds values with PHP's DateTime::format(), whose textual tokens (F, M, D, l — month and weekday names) are always English regardless of the Moodle language.

The result is internally consistent (client output matches server output), but month and weekday names never follow the site or user language. On a non-English Moodle site the clock will still display names such as April and Saturday.

This is a localisation limitation, not a defect or security issue — Moodle normally renders dates via userdate()/language packs. It is noted so the maintainer can decide whether English-only dates are intended.

Risk Assessment

Informational. There is no security or data-integrity impact. The only consequence is a user-experience/internationalisation gap on non-English installations, where date component names remain in English.

Context

getLangCode() is used throughout amd/src/main.js (monthsForLocale(), getDateInfo()) to construct Intl.DateTimeFormat instances that update the clocks every second and drive the converter. The hardcoded 'en' mirrors the server, which emits English names through DateTime::format() in dateinfo(), so the two stay aligned.

amd/src/main.js:33Source link unavailable — plugin was reviewed from zip without a matching git ref
Identified Code
const getLangCode = () => 'en';
Suggested Fix

If localised dates are desired, derive the locale from Moodle's current language (for example, pass the current language as a BCP-47 tag into the module) and drive Intl.DateTimeFormat with it. To keep the client and server consistent, the server-side dateinfo() output for F/M/D/l would also need to be localised (e.g. via IntlDateFormatter or Moodle's date helpers) rather than PHP's always-English DateTime::format(). If English-only dates are intentional, a short code comment documenting that decision would prevent future confusion.

Third-Party Libraries (1)
LibraryVersionLicenseDeclared
Choices.js
Enhances the converter's timezone `<select>` elements into searchable, multi-select dropdowns (loaded on demand from choices/assets and enhanced by amd/src/main.js).
11.2.1MIT
Additional AI Notes

Dynamic form access control is correctly delegated to core. The converter form extends core_form\dynamic_form, so submissions run through the core_form_dynamic_form web service, which is login-required by default. call_external_function() therefore enforces isloggedin() and require_sesskey() before the form is even constructed, and the dynamic_form constructor calls external_api::validate_context() (running require_login() on the block's course) using the user-supplied contextid. The form's own check_access_for_dynamic_submission() adds a redundant require_login(). The NO_MOODLE_COOKIES branches in converter.php are effectively unreachable as unauthenticated code paths, because core rejects NO_MOODLE_COOKIES for login-required services; they are harmless defensive code.

The dynamically-loaded stylesheet is correctly timed. block_timezoneclock::get_content() calls $this->page->requires->css(), which throws if the <head> has already been printed. This is safe here because block content is generated inside core_renderer::standard_head_html() (it calls ensure_content_created() for every region) before get_head_code() marks the head as done, so the stylesheet requirement is registered in time.

Minor input-typing observation (safe). In converter::definition() the "To timezones" field (timezones) is typed PARAM_NOTAGS while the "From timezone" field uses the tighter PARAM_TIMEZONE. This is not exploitable: every timezone value is passed through core_date::normalise_timezone() before use (which returns a valid zone or a safe default) and output is escaped. Using PARAM_TIMEZONE for both would be marginally cleaner but is not required.

Rendering all timezones can be heavy. When the converter is opened for a block with no preferred timezones configured, process_dynamic_submission() renders the full core_date::get_list_of_timezones() list (several hundred clocks), each auto-updating every second on the client. This is user-initiated and bounded, but on low-powered devices the per-second DOM updates for a very large list may be noticeable; capping or paginating the default list would improve responsiveness.

This review was generated by an AI system and may contain inaccuracies. Findings should be verified by a human reviewer before acting on them.