{"record":{"id":"7a9b6e44d2ceca77","repo":"ankane/pghero","slug":"unsafe-statement","errorCode":null,"errorMessage":"Unsafe statement","messagePattern":"Unsafe statement","errorType":"exception","errorClass":"ActiveRecord::StatementInvalid","httpStatus":null,"severity":"error","filePath":"lib/pghero/methods/explain.rb","lineNumber":24,"sourceCode":"        options = []\n        add_explain_option(options, \"ANALYZE\", analyze)\n        add_explain_option(options, \"VERBOSE\", verbose)\n        add_explain_option(options, \"SETTINGS\", settings)\n        add_explain_option(options, \"GENERIC_PLAN\", generic_plan)\n        add_explain_option(options, \"COSTS\", costs)\n        add_explain_option(options, \"BUFFERS\", buffers)\n        add_explain_option(options, \"WAL\", wal)\n        add_explain_option(options, \"TIMING\", timing)\n        add_explain_option(options, \"SUMMARY\", summary)\n        options << \"FORMAT #{explain_format(format)}\"\n\n        sql = \"(#{options.join(\", \")}) #{sql}\"\n        explanation = nil\n\n        # use transaction for safety\n        with_transaction(statement_timeout: (explain_timeout_sec * 1000).round, rollback: true) do\n          if (sql.delete_suffix(\";\").include?(\";\") || sql.upcase.include?(\"COMMIT\")) && !explain_safe?\n            raise ActiveRecord::StatementInvalid, \"Unsafe statement\"\n          end\n          explanation = execute(\"EXPLAIN #{sql}\").map { |v| v[\"QUERY PLAN\"] }.join(\"\\n\")\n        end\n\n        explanation\n      end\n\n      private\n\n      def explain_safe?\n        select_all(\"SELECT 1; SELECT 1\")\n        false\n      rescue ActiveRecord::StatementInvalid\n        true\n      end\n\n      def add_explain_option(options, name, value)\n        unless value.nil?","sourceCodeStart":6,"sourceCodeEnd":42,"githubUrl":"https://github.com/ankane/pghero/blob/7edb57986ffd36f9d64f0830c4ccc90a5eac46d6/lib/pghero/methods/explain.rb#L6-L42","documentation":"explain() runs user-supplied SQL inside a rolled-back transaction with a statement timeout. As an injection guard, it rejects any statement that - after trimming one trailing \";\" - still contains \";\" or whose uppercased text contains \"COMMIT\", unless explain_safe? proves the connection itself rejects multi-statement SQL (it probes SELECT 1; SELECT 1). When the statement trips the check on a connection that would happily execute multiple statements, PgHero raises ActiveRecord::StatementInvalid \"Unsafe statement\".","triggerScenarios":"database.explain(\"SELECT 1; DROP TABLE x\") - a genuine multi-statement payload; pasting two statements into the Explain tab; false positives: any query mentioning a column or literal containing the letters \"commit\" (e.g. SELECT committed_at FROM orders) because the check is a plain case-insensitive substring match on the whole SQL string.","commonSituations":"Users pasting multi-statement SQL from psql into the pghero Explain tab; models with commit/committed_at columns whose queries get explained via the query stats page; drivers or poolers that allow multiple statements in one call, which makes explain_safe? return false.","solutions":["Explain one statement at a time: strip everything after the first \";\" before calling explain","For the COMMIT false positive, remove or alias the offending token (e.g. select the column under a different expression) or wait for/use a version with a tighter check","If you control the call site, rescue ActiveRecord::StatementInvalid and show a clear message instead of letting it bubble as a 500"],"exampleFix":"# before - two statements in one call\ndatabase.explain(\"SELECT 1; SELECT 2\")\n\n# after - one statement per call\ndatabase.explain(\"SELECT 1\")\ndatabase.explain(\"SELECT 2\")","handlingStrategy":"try-catch","validationCode":"# reject what pghero's guard rejects, before calling explain\ncleaned = sql.strip.delete_suffix(\";\")\nif cleaned.include?(\";\") || cleaned.upcase.include?(\"COMMIT\")\n  raise ArgumentError, \"refusing multi-statement / COMMIT SQL\"\nend\ndatabase.explain(cleaned)","typeGuard":null,"tryCatchPattern":"begin\n  database.explain(sql)\nrescue ActiveRecord::StatementInvalid => e\n  if e.message.include?(\"Unsafe statement\")\n    # tell the user to submit a single statement instead of surfacing a 500\n    render_error(\"Explain accepts a single SQL statement without COMMIT\")\n  else\n    raise\n  end\nend","preventionTips":["Explain exactly one statement per call - split on ';' at the application boundary","Expect false positives on text containing 'commit' (e.g. committed_at columns) and reword or skip","Never feed raw user input into explain; the guard exists because EXPLAIN output is not always safe"],"tags":["pghero","explain","sql-injection","rails"],"backgroundTag":"sql-injection-guard","analyzedSha":"7edb57986ffd36f9d64f0830c4ccc90a5eac46d6","analyzedAt":"2026-08-21T17:33:54.942Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}