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 +4 -4
- data/CHANGELOG.md +33 -0
- data/app/assets/builds/profiler.js +20 -65
- data/app/controllers/profiler/api/toolbar_controller.rb +0 -2
- data/app/controllers/profiler/application_controller.rb +1 -3
- data/app/views/profiler/profiles/show.html.erb +3 -1
- data/lib/profiler/middleware/toolbar_injector.rb +94 -6
- data/lib/profiler/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc826508539b9892ab3317d52a95d77cdb162b4439a4086ae3c538239edb7eaa
|
|
4
|
+
data.tar.gz: 05c364a0cfeca030f37e635b35b150819f81f266de13137f98d33b9a9be29a53
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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: "
|
|
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
|
})();
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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__ =
|
|
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">▶</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.
|
data/lib/profiler/version.rb
CHANGED