rails-profiler 0.30.11 → 0.30.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7bb3bed351c863507d670d29552cb6751bb023300728a48e9baf9c718a53659
4
- data.tar.gz: 8af241a6ca0278f3ada6a4d0d88599e93b952f67057ba6b826ea36935976ba67
3
+ metadata.gz: fc826508539b9892ab3317d52a95d77cdb162b4439a4086ae3c538239edb7eaa
4
+ data.tar.gz: 05c364a0cfeca030f37e635b35b150819f81f266de13137f98d33b9a9be29a53
5
5
  SHA512:
6
- metadata.gz: 04d1ce9d5e1c0a2527ddb60280aa82e89e04ccc32a202a4f79e09b4a5fa87319b8f6d6c95a93f212a61cb4bc7213b92d15bb914567669a2f079eec502c075822
7
- data.tar.gz: 91db3673cbcd3debdcf9fc1e02690601c4a70aaf10f4a17a070eed4b7f9359ae6261b5a9256ab43b9a13108aec0fa61677dddc9b735f72bf8103261f7e8b24f5
6
+ metadata.gz: b0439a929e4b29fb9cc74ea0806135bfe10fb619bac6664c375aac1ae2b969f5adcd51a1f2d04322878c952d79430e6dec6350f4bda37665df26fe459b63ac0f
7
+ data.tar.gz: cedc00eb32c405fa556be7723fe2adde3ae7704c57e2053d6fcf8828c933deecb1b300833f7975fdd3239679c25512435c8ccf107e7d84925400f00ea661aa02
data/CHANGELOG.md CHANGED
@@ -19,6 +19,39 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.30.12] - 2026-10-04
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Security
27
+
28
+ - **Toolbar:** The toolbar endpoint (`/_profiler/api/toolbar/:token`) now answers `403` when the
29
+ profiler is disabled, like the rest of the API. It used to keep serving any profile left in
30
+ storage, request headers and environment included, after `enabled = false`. The toolbar is only
31
+ injected while the profiler is enabled, so it is not affected.
32
+ - **Profile page:** The profile embedded as JSON in the profile page is now escaped explicitly
33
+ (`<`, `>`, `&`, U+2028 and U+2029), whatever the application sets for
34
+ `ActiveSupport.escape_html_entities_in_json`. An application that turned that setting off let a
35
+ captured value (a parameter, a header, a body) close the `<script>` element and run JavaScript
36
+ in the application's origin. The token and the CSP nonce written into the application's pages
37
+ by the toolbar injector are escaped too.
38
+ - **Dashboard:** Captured bodies offered for download are no longer typed with their captured
39
+ content type: opened in a tab, a `text/html` or `image/svg+xml` body ran its scripts in the
40
+ application's origin. Previews keep their type for raster images and PDF only. Email previews
41
+ are rendered in a fully sandboxed iframe, with an opaque origin. The unused SQL highlighter,
42
+ which wrote captured SQL into the page as HTML, is removed.
43
+ - **Access control:** A disabled profiler now answers its API requests with a JSON error, like
44
+ any other refusal of the API, instead of plain text.
45
+ - **Toolbar:** The toolbar is now injected before the `</body>` that closes the page, looked for
46
+ outside comments, tags (attributes read as the browser reads them, quoted values included) and
47
+ the elements whose content is not markup (`<script>`, `<style>`, `<textarea>`, `<title>`,
48
+ `<xmp>`, `<iframe>`, `<noembed>`, `<noframes>`, `<noscript>`), instead of the first `</body>`
49
+ found. A `</body>` inside a script string of the page used to receive it, and the toolbar's own
50
+ `</script>` then turned the rest of that string into live markup. A page whose only `</body>`
51
+ sits in one of those, that leaves a comment, a tag, a quoted value or one of those elements
52
+ open, or that holds a `<plaintext>`, a double-escaped script or a CDATA section with a `>`
53
+ before its end, gets no toolbar.
54
+
22
55
  ## [0.30.11] - 2026-10-04
23
56
 
24
57
  <!-- stamped -->
@@ -4054,7 +4054,7 @@
4054
4054
  function DownloadTextButton({ text, mime }) {
4055
4055
  const [url, setUrl] = d2(null);
4056
4056
  y2(() => {
4057
- const objectUrl = URL.createObjectURL(new Blob([text], { type: mime }));
4057
+ const objectUrl = URL.createObjectURL(new Blob([text], { type: "application/octet-stream" }));
4058
4058
  setUrl(objectUrl);
4059
4059
  return () => URL.revokeObjectURL(objectUrl);
4060
4060
  }, [text, mime]);
@@ -4142,11 +4142,17 @@
4142
4142
  };
4143
4143
  return map[category] || "text/plain";
4144
4144
  }
4145
+ var INERT_PREVIEW_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif", "application/pdf"];
4146
+ function inertPreviewType(mime) {
4147
+ const m3 = mime.toLowerCase();
4148
+ return INERT_PREVIEW_TYPES.includes(m3) ? m3 : "application/octet-stream";
4149
+ }
4145
4150
  var PREVIEW_LIMIT = 500;
4146
4151
  var CSV_ROW_LIMIT = 10;
4147
4152
  function SmartBodyPreview({ body, encoding, headers }) {
4148
4153
  const [expanded, setExpanded] = d2(false);
4149
4154
  const [objectUrl, setObjectUrl] = d2(null);
4155
+ const [downloadUrl, setDownloadUrl] = d2(null);
4150
4156
  const category = detectContentType(headers);
4151
4157
  y2(() => {
4152
4158
  if (encoding !== "base64" || !body) return;
@@ -4154,19 +4160,27 @@
4154
4160
  const raw = atob(body);
4155
4161
  const bytes = new Uint8Array(raw.length);
4156
4162
  for (let i3 = 0; i3 < raw.length; i3++) bytes[i3] = raw.charCodeAt(i3);
4157
- const url = URL.createObjectURL(new Blob([bytes], { type: mime }));
4163
+ const url = URL.createObjectURL(new Blob([bytes], { type: inertPreviewType(mime) }));
4164
+ const download = URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" }));
4158
4165
  setObjectUrl(url);
4159
- return () => URL.revokeObjectURL(url);
4166
+ setDownloadUrl(download);
4167
+ return () => {
4168
+ URL.revokeObjectURL(url);
4169
+ URL.revokeObjectURL(download);
4170
+ };
4160
4171
  }, [body, encoding, headers]);
4161
4172
  if (!body) return /* @__PURE__ */ u3("span", { class: "profiler-text--muted profiler-text--xs", children: "empty" });
4162
4173
  if (encoding === "base64") {
4163
4174
  const mime = Object.entries(headers).find(([k3]) => k3.toLowerCase() === "content-type")?.[1]?.split(";")[0].trim() || "application/octet-stream";
4164
4175
  const filename = `body${mimeToExt(mime)}`;
4165
4176
  return /* @__PURE__ */ u3("div", { class: "profiler-body-binary", children: [
4166
- objectUrl && (category === "image" || category === "svg") && /* @__PURE__ */ u3("img", { src: objectUrl, alt: "response preview", style: "max-width:100%;max-height:300px;display:block;margin-bottom:8px;border-radius:4px;border:1px solid var(--profiler-border)" }),
4177
+ objectUrl && category === "image" && /* @__PURE__ */ u3("img", { src: objectUrl, alt: "response preview", style: "max-width:100%;max-height:300px;display:block;margin-bottom:8px;border-radius:4px;border:1px solid var(--profiler-border)" }),
4178
+ category === "svg" && // A data: URL rather than a blob: one: an <img> runs no script, and browsers refuse
4179
+ // to open a data: URL in a tab of its own.
4180
+ /* @__PURE__ */ u3("img", { src: `data:image/svg+xml;base64,${body}`, alt: "response preview", style: "max-width:100%;max-height:300px;display:block;margin-bottom:8px;border-radius:4px;border:1px solid var(--profiler-border)" }),
4167
4181
  objectUrl && category === "pdf" && /* @__PURE__ */ u3("iframe", { src: objectUrl, class: "profiler-body-preview-frame", title: "PDF preview" }),
4168
4182
  /* @__PURE__ */ u3("div", { style: "display:flex;gap:8px;margin-top:4px", children: [
4169
- objectUrl && /* @__PURE__ */ u3("a", { href: objectUrl, download: filename, class: "profiler-body-download-btn profiler-text--xs", children: [
4183
+ downloadUrl && /* @__PURE__ */ u3("a", { href: downloadUrl, download: filename, class: "profiler-body-download-btn profiler-text--xs", children: [
4170
4184
  "Download ",
4171
4185
  mime
4172
4186
  ] }),
@@ -8615,7 +8629,7 @@
8615
8629
  "iframe",
8616
8630
  {
8617
8631
  srcdoc: email.body_html ?? void 0,
8618
- sandbox: "allow-same-origin",
8632
+ sandbox: "",
8619
8633
  style: { width: "100%", height: "300px", border: "1px solid var(--profiler-border)", borderRadius: "var(--profiler-radius-md)", background: "#fff", display: "block" }
8620
8634
  }
8621
8635
  ),
@@ -9421,62 +9435,6 @@ Duration: ${event.duration.toFixed(2)}ms`;
9421
9435
  return "#dcdcaa";
9422
9436
  }
9423
9437
 
9424
- // app/assets/typescript/profiler/sql-formatter.ts
9425
- var SQL_KEYWORDS = [
9426
- "SELECT",
9427
- "FROM",
9428
- "WHERE",
9429
- "INSERT",
9430
- "UPDATE",
9431
- "DELETE",
9432
- "JOIN",
9433
- "LEFT",
9434
- "RIGHT",
9435
- "INNER",
9436
- "OUTER",
9437
- "ON",
9438
- "AS",
9439
- "AND",
9440
- "OR",
9441
- "NOT",
9442
- "IN",
9443
- "EXISTS",
9444
- "LIKE",
9445
- "ORDER",
9446
- "BY",
9447
- "GROUP",
9448
- "HAVING",
9449
- "LIMIT",
9450
- "OFFSET",
9451
- "DISTINCT",
9452
- "COUNT",
9453
- "SUM",
9454
- "AVG",
9455
- "MAX",
9456
- "MIN",
9457
- "CASE",
9458
- "WHEN",
9459
- "THEN",
9460
- "ELSE",
9461
- "END"
9462
- ];
9463
- function formatSQL(element) {
9464
- const sql = element.textContent || "";
9465
- const formatted = highlightSQL(sql);
9466
- element.innerHTML = formatted;
9467
- }
9468
- function highlightSQL(sql) {
9469
- let highlighted = sql;
9470
- SQL_KEYWORDS.forEach((keyword) => {
9471
- const regex = new RegExp(`\\b${keyword}\\b`, "gi");
9472
- highlighted = highlighted.replace(regex, `<span class="sql-keyword">${keyword}</span>`);
9473
- });
9474
- highlighted = highlighted.replace(/'([^']*)'/g, `<span class="sql-string">'$1'</span>`);
9475
- highlighted = highlighted.replace(/\b(\d+)\b/g, '<span class="sql-number">$1</span>');
9476
- highlighted = highlighted.replace(/--([^\n]*)/g, '<span class="sql-comment">--$1</span>');
9477
- return highlighted;
9478
- }
9479
-
9480
9438
  // app/assets/typescript/profiler/theme.ts
9481
9439
  var STORAGE_KEY2 = "profiler-theme";
9482
9440
  var THEME_ATTRIBUTE = "data-theme";
@@ -9663,8 +9621,5 @@ Duration: ${event.duration.toFixed(2)}ms`;
9663
9621
  if (timeline) {
9664
9622
  initTimeline(timeline);
9665
9623
  }
9666
- document.querySelectorAll('pre[data-language="sql"]').forEach((block) => {
9667
- formatSQL(block);
9668
- });
9669
9624
  });
9670
9625
  })();
@@ -3,8 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class ToolbarController < Profiler::ApplicationController
6
- skip_before_action :check_authorization
7
-
8
6
  def show
9
7
  profile = Profiler.storage.load(params[:token])
10
8
 
@@ -14,9 +14,7 @@ module Profiler
14
14
  private
15
15
 
16
16
  def check_authorization
17
- unless Profiler.configuration.enabled
18
- render plain: "Profiler is disabled", status: :forbidden
19
- end
17
+ deny("Profiler is disabled") unless Profiler.configuration.enabled
20
18
  end
21
19
 
22
20
  def authorize_request
@@ -1,4 +1,6 @@
1
1
  <div id="profiler-show" data-embedded="<%= @embedded %>"></div>
2
+ <%# json_escape turns <, >, & and U+2028/U+2029 into \u escapes, so that no captured value can
3
+ close the element, whatever the application sets for escape_html_entities_in_json. %>
2
4
  <script type="application/json" id="profiler-show-data">
3
- <%= @profile_data.to_json.html_safe %>
5
+ <%= raw json_escape(@profile_data.to_json) %>
4
6
  </script>
@@ -1,9 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require "strscan"
5
+ require "active_support/core_ext/string/output_safety"
6
+
3
7
  module Profiler
4
8
  module Middleware
5
9
  class ToolbarInjector
6
- CLOSING_BODY_TAG = "</body>"
10
+ # The only characters HTML counts as white space between attributes (\s also takes \v).
11
+ WS = "[\\t\\n\\f\\r ]"
12
+ # Elements whose content the browser does not parse as markup: a </body> inside one of
13
+ # them is text, not the end of the page. Each maps to the end tag that closes it.
14
+ RAW_TEXT_ELEMENTS = %w[script style textarea title xmp iframe noembed noframes noscript]
15
+ .to_h { |name| [name, %r{</#{name}(?=#{WS}|[/>])}i] }.freeze
16
+ # Never closed: whatever follows it is text.
17
+ PLAINTEXT = "plaintext"
18
+ # An attribute as the HTML tokenizer reads it: the name may start with = (or a quote),
19
+ # and a value only follows a name, after =. A quoted value may hold a > or a </body>.
20
+ ATTRIBUTE = %r{
21
+ [^\t\n\f\r\ />][^\t\n\f\r\ />=]*+
22
+ (?:#{WS}*+(?:=#{WS}*+(?:"[^"]*+"|'[^']*+'|[^\t\n\f\r\ >"'][^\t\n\f\r\ >]*+|(?=>))|(?!=)))
23
+ }x
24
+ # The rest of a tag, up to its >. A quoted value that is never closed fails the match,
25
+ # as does a tag without its >. Possessive, so that a failure costs no backtracking.
26
+ TAG_REST = %r{(?:#{WS}++|/|#{ATTRIBUTE})*+>}
27
+ # A whole start or end tag, read after its <: the slash and the name are captured.
28
+ TAG = %r{(/)?([a-z][^\t\n\f\r\ />]*+)#{TAG_REST}}i
7
29
 
8
30
  def initialize(body, token, nonce = nil)
9
31
  @body = body
@@ -13,9 +35,12 @@ module Profiler
13
35
 
14
36
  def inject
15
37
  content = extract_content(@body)
16
- return @body unless content.include?(CLOSING_BODY_TAG)
38
+ # Bytes, so that a page that is not valid UTF-8 can still be scanned.
39
+ bytes = content.b
40
+ position = closing_body_position(bytes)
41
+ return @body unless position
17
42
 
18
- injected_content = content.sub(CLOSING_BODY_TAG, toolbar_html + CLOSING_BODY_TAG)
43
+ injected_content = bytes.insert(position, toolbar_html.b).force_encoding(content.encoding)
19
44
 
20
45
  # Return as array for Rack compatibility
21
46
  [injected_content]
@@ -23,12 +48,70 @@ module Profiler
23
48
 
24
49
  private
25
50
 
51
+ # Byte offset of the </body> that closes the page: the last one outside comments, tags
52
+ # (an attribute value) and raw-text elements, where a page can carry the string
53
+ # "</body>" (a script building markup, a cached fragment in a final comment). nil when
54
+ # there is none, or when the page leaves a comment, a tag, a quoted value or a raw-text
55
+ # element open, or holds a <plaintext> or a double-escaped script: no toolbar rather than
56
+ # a toolbar in the wrong place.
57
+ def closing_body_position(bytes)
58
+ scanner = StringScanner.new(bytes)
59
+ position = nil
60
+
61
+ while scanner.skip_until(/</)
62
+ start = scanner.pos - 1
63
+
64
+ if scanner.skip(TAG)
65
+ name = scanner[2]
66
+ name = name.downcase if name.match?(/[A-Z]/)
67
+
68
+ if scanner[1]
69
+ position = start if name == "body"
70
+ next
71
+ end
72
+ return nil if name == PLAINTEXT
73
+ next unless (end_tag = RAW_TEXT_ELEMENTS[name])
74
+
75
+ text_start = scanner.pos
76
+ return nil unless scanner.skip_until(end_tag)
77
+ return nil if name == "script" && double_escaped?(bytes, text_start, scanner.pos - scanner.matched_size)
78
+ return nil unless scanner.skip(TAG_REST)
79
+ elsif scanner.match?(%r{/?[a-z]}i)
80
+ # A tag, or a quoted value in it, that is never closed.
81
+ return nil
82
+ elsif scanner.skip(/!--/)
83
+ # <!--> and <!---> are complete comments.
84
+ next if scanner.skip(/-?>/) || scanner.skip_until(/-->/)
85
+
86
+ return nil
87
+ elsif scanner.skip(/!\[CDATA\[/)
88
+ # A bogus comment up to the first > in HTML, a CDATA section up to ]]> in <svg> or
89
+ # <math>: go on only when both end at the same place.
90
+ return nil unless scanner.skip_until(/>/) && bytes.byteslice(scanner.pos - 3, 3) == "]]>"
91
+ elsif scanner.skip(%r{[!?/]})
92
+ # <!DOCTYPE>, <?xml ?>, </ not followed by a letter: a bogus comment, up to its >.
93
+ return nil unless scanner.skip_until(/>/)
94
+ end
95
+ end
96
+
97
+ position
98
+ end
99
+
100
+ # In a script, <!-- followed by <script enters a state where </script> does not close it.
101
+ # Only the script itself is searched, so that a page of many scripts is read in linear
102
+ # time.
103
+ def double_escaped?(bytes, from, to)
104
+ text = bytes.byteslice(from, to - from)
105
+ comment = text.index("<!--")
106
+ comment ? text.match?(%r{<script(?:#{WS}|[/>])}i, comment) : false
107
+ end
108
+
26
109
  def ajax_interceptor_script
27
110
  return "" unless Profiler.configuration.track_ajax
28
111
 
29
112
  <<~HTML
30
113
  <script#{nonce_attr}>
31
- window.__PROFILER_PARENT_TOKEN__ = '#{@token}';
114
+ window.__PROFILER_PARENT_TOKEN__ = #{js_token};
32
115
  </script>
33
116
  <script#{nonce_attr}>
34
117
  #{ajax_interceptor_code}
@@ -63,15 +146,20 @@ module Profiler
63
146
  <<~HTML
64
147
  #{ajax_interceptor_script}
65
148
  <style>#{toolbar_styles}</style>
66
- <div id="profiler-toolbar" class="profiler-root" data-token="#{@token}"></div>
149
+ <div id="profiler-toolbar" class="profiler-root" data-token="#{ERB::Util.html_escape(@token)}"></div>
67
150
  <button id="profiler-toolbar-toggle" class="profiler-root" title="Toggle profiler (Alt+P)"><span class="profiler-toggle-icon">&#9654;</span></button>
68
151
  <script#{nonce_attr}>(function(){var c=localStorage.getItem('profiler-toolbar-collapsed')==='true',t=localStorage.getItem('profiler-theme'),theme=t==='light'?'light':t==='dark'?'dark':(window.matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'),el=document.getElementById('profiler-toolbar'),tog=document.getElementById('profiler-toolbar-toggle');if(c){el.style.cssText='animation:none!important;transform:translateX(calc(100% + 44px))';tog.dataset.collapsed='true';}el.setAttribute('data-theme',theme);tog.setAttribute('data-theme',theme);})();</script>
69
152
  <script src="/_profiler/assets/profiler-toolbar.js" defer#{nonce_attr}></script>
70
153
  HTML
71
154
  end
72
155
 
156
+ # The token as a JavaScript string literal that cannot close the <script> element.
157
+ def js_token
158
+ ERB::Util.json_escape(@token.to_s.to_json)
159
+ end
160
+
73
161
  def nonce_attr
74
- @nonce ? " nonce=\"#{@nonce}\"" : ""
162
+ @nonce ? " nonce=\"#{ERB::Util.html_escape(@nonce)}\"" : ""
75
163
  end
76
164
 
77
165
  # Thermal design system — self-contained CSS for the injected toolbar.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.30.11"
4
+ VERSION = "0.30.12"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.30.11
4
+ version: 0.30.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy