Skip to content

[Responsible Disclosure] 3 security defects — CRITICAL + HIGH + MEDIUM (grid constraint enforcement, schedule validation, notification) #4

Description

@dom-omg

Repo: github.com/hq-opensource/predictive-control
Channel: GitHub Issue — label: security / vulnerability
Subject: [Responsible Disclosure] 3 security defects in predictive-control — CRITICAL + HIGH + MEDIUM


Hello,

My name is Dominik Blain, Co-Founder at QreativeLab (Gatineau, QC). I conduct formal verification analysis of open-source software using the Z3 SMT solver, AST analysis, and runtime verification.

I identified three defects in hq-opensource/predictive-control affecting grid constraint enforcement, schedule validation, and overload notification. Two are confirmed by runtime execution, one is formally proven via Z3 SMT solver.

I am disclosing these under a 90-day responsible disclosure window. I will not publish technical details publicly before that window expires or before a fix is confirmed — whichever comes first.


Finding HQ-001 — CRITICAL

File: src/cold_pickup_mpc/real_time/power_limit_mpc.py, line 146
CWE: CWE-704 — Incorrect Type Conversion
Type: Operational Defect (OD)

Vulnerable code:

security_limit = float(os.getenv("SECURITY_LIMIT", "0,5"))

Root cause:
The default value "0,5" uses French decimal notation (comma separator). Python's float() strictly requires a period separator. The call raises ValueError: could not convert string to float: '0,5' on every iteration when SECURITY_LIMIT is not explicitly set in the environment.

The ValueError propagates into the outer try/except block in _needs_curtailment(), which silently catches the exception and sleeps. Grid constraint enforcement is permanently disabled in any deployment without an explicit SECURITY_LIMIT environment variable.

Runtime verification (reproducible in 5 seconds):

>>> float("0,5")
ValueError: could not convert string to float: '0,5'

Impact:
_needs_curtailment() never returns True. Buildings can exceed configured power limits indefinitely without triggering the curtailment response. No error is logged at the constraint check level — the failure is silent.

Fix:

security_limit = float(os.getenv("SECURITY_LIMIT", "0.5"))

Finding HQ-002 — HIGH

File: src/cold_pickup_mpc/real_time/power_limit_mpc.py, line 304
CWE: CWE-670 — Always-Incorrect Control Flow Implementation
Type: Incorrect Reasoning (IR)

Vulnerable code:

last_limit  = max(self.power_limit.keys())   # maximum timestamp
first_limit = min(self.power_limit.keys())   # minimum timestamp
if last_limit < timestamp < first_limit:     # ← always False
    return None

Root cause:
last_limit is by definition ≥ first_limit. The compound condition last_limit < timestamp < first_limit requires simultaneously that timestamp > last_limit and timestamp < first_limit, which is mathematically impossible when last_limit ≥ first_limit.

Formal Z3 proof:

Variables: last_t (Int), first_t (Int), ts (Int)
Constraint added: last_t >= first_t   (invariant: max >= min)
Query: Exists ts such that last_t < ts < first_t
Result: UNSAT — no such ts exists
Conclusion: the out-of-range check is dead code

Impact:
Timestamps that fall outside the configured schedule window are never detected. The real-time controller (RtcMpc) may apply power limits when none should be in effect, or may continue operating outside the intended scheduling window without any boundary enforcement.

Fix:

if timestamp < first_limit or timestamp > last_limit:
    return None

Finding HQ-004 — MEDIUM

File: src/cold_pickup_mpc/real_time/power_limit_mpc.py, line 279
CWE: CWE-476 — NULL Pointer Dereference
Type: Control-flow Issue (CI)

Vulnerable code:

def _trigger_notification(self):
    total_power = self._get_total_consumption()          # returns float | None
    current_power_limit = self._get_current_power_limit(...)  # returns float | None
    if total_power > current_power_limit:                # TypeError if either is None
        self._send_overload_notification(...)

Root cause:
Both _get_total_consumption() and _get_current_power_limit() can return None on database query failure. The comparison None > float raises TypeError. The exception is caught by the outer loop's try/except, and the overload notification is never sent.

Runtime verification:

>>> None > 10.0
TypeError: '>' not supported between instances of 'NoneType' and 'float'

Impact:
Any transient database failure (connection timeout, pool exhaustion) silences all overload notifications for the duration of the failure. Operators receive no alert about buildings exceeding power limits.

Fix:

if total_power is not None and current_power_limit is not None:
    if total_power > current_power_limit:
        self._send_overload_notification(...)

Timeline

  • 2026-04-02 — Initial disclosure sent
  • 2026-07-01 — 90-day disclosure window expires (public disclosure if no response)

I am happy to provide additional technical context, Z3 proof scripts, or clarification on any of the above findings.

Thank you for maintaining this project as open source. It is precisely because it is public that we were able to identify and report these issues.


Dominik Blain
Co-Founder, QreativeLab
Gatineau, QC
security@qreativelab.io
https://cobalt-live.vercel.app

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions