Spaces:
Running
Running
File size: 42,201 Bytes
ec77a75 e9aa420 16f3b0f e9aa420 16f3b0f e9aa420 16f3b0f e9aa420 16f3b0f ec77a75 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 | <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Rails ERB XSS check — find unsafe output in templates</title>
<meta name="description" content="This free browser tool finds unescaped output in Rails ERB templates and names an escaping helper for each flagged line.">
<style>
:root{color-scheme:light}body{margin:0;font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif;background:#faf9f5;color:#1a1512}
.wrap{max-width:960px;margin:0 auto;padding:28px 18px 60px}h1{font-size:26px;margin:0 0 6px}.lede{color:#4a403a;margin:0 0 16px}
textarea{width:100%;min-height:260px;font:13px/1.45 ui-monospace,Menlo,Consolas,monospace;padding:12px;border:1px solid #d9d0c4;border-radius:8px;box-sizing:border-box}
.bar{display:flex;gap:10px;align-items:center;margin:12px 0}button{font:600 15px system-ui;padding:10px 18px;border:0;border-radius:8px;background:#1a1512;color:#fff;cursor:pointer}
input[type=date]{font:14px system-ui;padding:8px;border:1px solid #d9d0c4;border-radius:6px}
table{border-collapse:collapse;width:100%;font-size:14px;margin-top:10px}th,td{text-align:left;padding:8px 10px;border-bottom:1px solid #eee;vertical-align:top}th{font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:#7a6e65}
.sev.error{color:#a8551f;font-weight:700}.sev.warn,.sev.warning{color:#96691f}.ok{color:#2a6446;font-weight:600}.sum{font-weight:600}
.note{font-size:13px;color:#7a6e65;margin-top:18px}#paid{margin-top:22px;padding:14px 16px;border:1px solid #e2d8cb;border-radius:10px;background:#fffdfa}
</style>
<script data-grow-initializer="">!(function(){window.growMe||((window.growMe=function(e){window.growMe._.push(e);}),(window.growMe._=[]));var e=document.createElement("script");(e.type="text/javascript"),(e.src="https://faves.grow.me/main.js"),(e.defer=!0),e.setAttribute("data-grow-faves-site-id","U2l0ZTphZjljMzgyMC0yMmU5LTRhYjgtYTIxNS03OTRhMGY1N2ExYzM=");var t=document.getElementsByTagName("script")[0];t.parentNode.insertBefore(e,t);})();</script>
<!--yj-canon-s101--><link rel="canonical" href="https://getreadystack.com/tools/erb-escape-audit"></head>
<body>
<div class="wrap">
<h1>Rails ERB XSS check — find unsafe output in templates</h1>
<p class="lede">Finds unescaped output in Rails ERB templates - raw, html_safe, <%==, script and href context - and names the escaping helper that fixes each line. Runs entirely in your browser — nothing is uploaded.</p>
<textarea id="src" spellcheck="false"><meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'">
<article class="product">
<h1><%= @product.name %></h1>
<div class="description"><%= raw @product.description_html %></div>
<p class="blurb"><%= @product.blurb.html_safe %></p>
<div class="announcement"><%== @announcement.body %></div>
<%= render inline: @product.custom_template %>
<p><a href="<%= @product.homepage %>">Vendor site</a></p>
<p><%= link_to "Back to results", params[:return_to] %></p>
<button onclick="track(<%= @product.id %>)">Buy</button>
<img src=<%= @product.image_url %> alt="Product">
<div class="notes"><%= content_tag(:div, @product.notes, {}, false) %></div>
<div class="review"><%= sanitize(@review.body) %></div>
<div class="review"><%= simple_format(@review.body, {}, sanitize: false) %></div>
</article>
<script>
var name = "<%= @user.name %>";
var cfg = <%= @config.to_json %>;
window.init(name, cfg);
</script>
<style>
.banner { background: url(<%= @product.banner_url %>); }
</style></textarea>
<div class="bar"><button id="go">Check</button><label>as of <input type="date" id="today"></label></div>
<div id="out" aria-live="polite"></div>
<div id="paid"><strong>Free here:</strong> Audit the ERB template you have open: every unescaped-output line, with the escaping helper that fixes it.. <strong>In the editor:</strong> Scan every .erb file in the workspace at once and export a dated Markdown/CSV evidence report you keep. — that part asks for a licence key.</div>
<p class="note">Same engine as the VS Code extension, byte for byte.</p>
</div>
<script>var ERBAUDIT_RULES = [
{"id":"raw_helper","sev":"critical","ctx":"any","re":"\u003c%=[^%]*?\\braw\\b",
"msg":"raw() turns the value into HTML and skips escaping - a stored '\u003cscript\u003e' in that value runs in every visitor's browser. Drop raw() (\u003c%= value %\u003e escapes by default) or use sanitize(value, tags: %w[b i a])."},
{"id":"html_safe_call","sev":"critical","ctx":"any","re":"\u003c%=[^%]*?\\.html_safe\\b",
"msg":"#html_safe marks the string as already-escaped, so ERB prints it verbatim. Remove .html_safe, or build the markup with tag helpers (content_tag/safe_join) so only the literal parts are trusted."},
{"id":"double_equals_tag","sev":"critical","ctx":"any","re":"\u003c%==",
"msg":"\u003c%== is the shorthand for \u003c%= raw - identical unescaped output with no visible 'raw' to grep for. Change it to \u003c%= and escape explicitly where you really need HTML."},
{"id":"render_inline","sev":"critical","ctx":"any","re":"render\\s+inline:",
"msg":"render inline: compiles the string as an ERB template; user text reaching it is server-side template injection, not just XSS. Render a real template file and pass the value as a local."},
{"id":"script_interpolation","sev":"high","ctx":"script","re":"\u003c%=","not":"json_escape|escape_javascript|\\bj\\s*\\(|\\.to_json",
"msg":"ERB's HTML escaping does not make a value safe inside a \u003cscript\u003e block - quotes and \u003c/script\u003e both break out. Wrap it: \u003c%= j(value) %\u003e (escape_javascript) for a JS string."},
{"id":"to_json_unescaped","sev":"high","ctx":"script","re":"\\.to_json\\b","not":"json_escape",
"msg":"to_json inside \u003cscript\u003e leaves '\u003c/script\u003e' and '\u003c!--' intact, so one string field closes your script tag. Use \u003c%= json_escape(value.to_json) %\u003e, or move the data to a data- attribute and JSON.parse it."},
{"id":"href_interpolation","sev":"high","ctx":"any","re":"\\bhref\\s*=\\s*[\"']?\u003c%=","not":"url_for|_path\\b|_url\\b|mailto:|https?://",
"msg":"A URL taken straight from a record can start with 'javascript:' or 'data:', which fires on click even though the attribute was HTML-escaped. Pass it through a route helper, or check the scheme against an http/https allowlist first."},
{"id":"link_to_params","sev":"high","ctx":"any","re":"link_to[^%]*\\bparams\\[",
"msg":"link_to with a params[] destination is an open redirect and a javascript:-URL sink - the attacker supplies the query string. Redirect through a named route, or validate the value against a list of allowed paths."},
{"id":"event_attr_interpolation","sev":"high","ctx":"any","re":"\\bon[a-z]{3,15}\\s*=\\s*[\"'][^\"']*\u003c%=",
"msg":"An on* attribute is a JavaScript context: HTML escaping still leaves the value executable once the browser decodes the entity. Move the handler to a Stimulus controller or an addEventListener and pass the value in a data- attribute."},
{"id":"unquoted_attribute","sev":"high","ctx":"any","re":"[a-zA-Z_-]+=\u003c%=",
"msg":"An unquoted attribute value ends at the first space, so a value containing a space can add attributes such as onerror=. Quote it: attr=\"\u003c%= value %\u003e\"."},
{"id":"content_tag_escape_false","sev":"high","ctx":"any","re":"content_tag\\(.*,\\s*false\\s*[,)]",
"msg":"The fourth argument of content_tag is 'escape'; passing false prints the body as HTML. Leave it at true and pass already-built tag helpers as the body instead."},
{"id":"sanitize_no_allowlist","sev":"medium","ctx":"any","re":"\\bsanitize\\s*\\(","not":"tags:",
"msg":"sanitize() without a tags: allowlist falls back to Rails' default list, which is wider than most views need and changes between releases. State the allowlist: sanitize(value, tags: %w[b i em a], attributes: %w[href])."},
{"id":"simple_format_unsanitized","sev":"medium","ctx":"any","re":"simple_format\\([^)]*sanitize:\\s*false",
"msg":"simple_format(..., sanitize: false) keeps every tag in the text and only adds paragraphs around it. Remove the option, or sanitize the value yourself with an explicit tags: allowlist before formatting."},
{"id":"style_interpolation","sev":"medium","ctx":"style","re":"\u003c%=",
"msg":"A value interpolated into a \u003cstyle\u003e block lands in a CSS context, where url(...) and expression-like payloads survive HTML escaping. Set the value as an inline style attribute on the element, or expose it as a CSS custom property you control."},
{"id":"csp_unsafe_inline","sev":"medium","ctx":"any","re":"Content-Security-Policy[^\u003e]*unsafe-inline",
"msg":"A policy carrying 'unsafe-inline' for script-src removes the browser's last line of defence against exactly the injections above. Move inline scripts into asset files and use the nonce Rails' content_security_policy generator provides."}
]
;</script>
<script>/* ERB Escape Audit - one brain, used by the VS Code extension and by the free web page. */
(function () {
var RULES = (typeof module !== 'undefined' && module.exports)
? require('./rules.json')
: window.ERBAUDIT_RULES;
var SEV_ORDER = { critical: 0, high: 1, medium: 2 };
function compiled() {
return RULES.map(function (r) {
return {
id: r.id,
sev: r.sev,
ctx: r.ctx || 'any',
msg: r.msg,
re: new RegExp(r.re),
not: r.not ? new RegExp(r.not) : null
};
});
}
/* Script/style context covers the lines between the tags. The opening tag's
own attributes are markup, not JavaScript, so they keep the "any" context. */
function contextOf(line, state) {
var ctx = state.open || 'any';
var opened = /<(script|style)\b[^>]*>/i.exec(line);
var closed = /<\/(script|style)\s*>/i.test(line);
if (opened && closed) {
ctx = opened[1].toLowerCase();
state.open = null;
} else if (closed) {
state.open = null;
} else if (opened && !/\bsrc\s*=/i.test(opened[0])) {
state.open = opened[1].toLowerCase();
}
return ctx;
}
function check(text, opts) {
opts = opts || {};
var rules = compiled();
var lines = String(text == null ? '' : text).split(/\r?\n/);
var state = { open: null };
var findings = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
var ctx = contextOf(line, state);
if (/^\s*<%#/.test(line)) continue;
for (var k = 0; k < rules.length; k++) {
var r = rules[k];
if (r.ctx !== 'any' && r.ctx !== ctx) continue;
if (!r.re.test(line)) continue;
if (r.not && r.not.test(line)) continue;
findings.push({
check: r.id,
sev: r.sev,
msg: r.msg,
line: i + 1,
excerpt: line.trim().slice(0, 120)
});
}
}
findings.sort(function (a, b) {
var d = SEV_ORDER[a.sev] - SEV_ORDER[b.sev];
return d !== 0 ? d : a.line - b.line;
});
return { findings: findings, scanned_on: opts.today || '' };
}
var API = { engine: { check: check }, RULES: RULES, RULE_COUNT: RULES.length };
if (typeof module !== 'undefined' && module.exports) module.exports = API;
if (typeof window !== 'undefined') window.ERBAUDIT = API;
})();
</script>
<script>// ⛔뼈대 — kit/scaffold.py 가 찍는다 (s143). findings [{check,sev,msg,line?}] → 글/HTML. 같은 파일이 Node 와 브라우저에서 산다.
'use strict';
function escapeHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
function summaryLine(res) {
const f = (res && res.findings) || []; const e = f.filter(function (x) { return String(x.sev || 'error').toLowerCase().startsWith('err'); }).length;
return f.length ? (f.length + ' finding' + (f.length === 1 ? '' : 's') + ' (' + e + ' error' + (e === 1 ? '' : 's') + ')') : 'Clean — no findings.';
}
function toText(res, label) {
const f = (res && res.findings) || [];
const lines = ['== ' + (label || '') + ' — ' + summaryLine(res)];
f.forEach(function (x) { lines.push(' ' + String(x.sev || 'error').toUpperCase().slice(0, 4) + ' ' + (x.line ? 'L' + x.line + ' ' : '') + (x.check || x.id || '') + ' ' + (x.msg || x.message || '')); });
return lines.join('\n');
}
function toHtml(res) {
const f = (res && res.findings) || [];
if (!f.length) return '<p class="ok">' + escapeHtml(summaryLine(res)) + '</p>';
const rows = f.map(function (x) { return '<tr><td class="sev ' + escapeHtml(String(x.sev || 'error')) + '">' + escapeHtml(String(x.sev || 'error')) + '</td><td>' + (x.line ? 'L' + escapeHtml(x.line) : '') + '</td><td><code>' + escapeHtml(x.check || x.id || '') + '</code></td><td>' + escapeHtml(x.msg || x.message || '') + '</td></tr>'; }).join('');
return '<p class="sum">' + escapeHtml(summaryLine(res)) + '</p><table><thead><tr><th>Level</th><th>Line</th><th>Check</th><th>What</th></tr></thead><tbody>' + rows + '</tbody></table>';
}
var ERBREPORT = { escapeHtml: escapeHtml, summaryLine: summaryLine, toText: toText, toHtml: toHtml };
if (typeof module !== 'undefined' && module.exports) module.exports = ERBREPORT;
else window.ERBREPORT = ERBREPORT;
</script>
<script>
(function(){
var src=document.getElementById('src'), out=document.getElementById('out'), go=document.getElementById('go'), today=document.getElementById('today');
today.value=new Date().toISOString().slice(0,10);
function run(){ try { var res=window.ERBAUDIT.engine.check(src.value,{today:today.value}); out.innerHTML=window.ERBREPORT.toHtml(res); } catch(e){ out.innerHTML='<p class="sev error">Could not run: '+String(e.message||e)+'</p>'; } }
go.onclick=run; if (src.value.trim()) run();
})();
</script>
<section data-yj-install="free" style="margin:2rem auto 1rem;max-width:46rem;padding:1.25rem 1.25rem;border:1px solid #cfe3d6;border-radius:12px;background:#f6fbf8;font:16px/1.6 system-ui,-apple-system,Segoe UI,sans-serif"><h2 style="margin:0 0 .4rem;font-size:1.2rem">Install free</h2><p style="margin:0 0 .8rem;color:#41505f">Free for the file open in your editor - no key, no limit. The workspace sweep and the report ask for a key.</p><a href="https://www.npmjs.com/package/@readystack/erb-escape-audit" rel="noopener" data-yj-install-door="npm" onclick="try{navigator.sendBeacon('/api/ev',new Blob([JSON.stringify({t:'install',slug:'erb-escape-audit',src:'npm',path:location.pathname})],{type:'application/json'}))}catch(e){}" style="display:inline-block;margin:0 .5rem .5rem 0;padding:.6rem 1rem;border-radius:8px;background:#0f7b3f;color:#fff;text-decoration:none;font-weight:600">npm</a><a href="https://www.npmjs.com/package/@readystack/erb-escape-audit" rel="noopener" data-yj-install-door="mcp" onclick="try{navigator.sendBeacon('/api/ev',new Blob([JSON.stringify({t:'install',slug:'erb-escape-audit',src:'mcp',path:location.pathname})],{type:'application/json'}))}catch(e){}" style="display:inline-block;margin:0 .5rem .5rem 0;padding:.6rem 1rem;border-radius:8px;background:#0f7b3f;color:#fff;text-decoration:none;font-weight:600">MCP server (npx)</a><a href="https://hub.docker.com/r/getreadystack/erb-escape-audit" rel="noopener" data-yj-install-door="docker" onclick="try{navigator.sendBeacon('/api/ev',new Blob([JSON.stringify({t:'install',slug:'erb-escape-audit',src:'docker',path:location.pathname})],{type:'application/json'}))}catch(e){}" style="display:inline-block;margin:0 .5rem .5rem 0;padding:.6rem 1rem;border-radius:8px;background:#0f7b3f;color:#fff;text-decoration:none;font-weight:600">Docker Hub</a><pre style="margin:.5rem 0 0;padding:.6rem .8rem;background:#f3f5f8;border-radius:8px;font-size:.9rem;overflow:auto">npx @readystack/erb-escape-audit <file>
npx @readystack/erb-escape-audit --mcp
docker run --rm -v "$PWD:/w" getreadystack/erb-escape-audit /w</pre><p style="margin:.6rem 0 0;font-size:.92rem;color:#41505f"><a href="https://getreadystack.com/api/buy/cl/polar_cl_l6iN1uWt0FwWu7tBsczD0jWpP2vxFM54Wdwqb3KPi1G" rel="nofollow" style="color:#0f7b3f;text-decoration:underline">Team? One key for every ReadyStack linter, 5 seats, $149 once</a></p></section>
<section data-yj-alert="rule" style="margin:1rem auto 1.5rem;max-width:46rem;padding:1.1rem 1.25rem;border:1px solid #d9dee6;border-radius:12px;background:#fbfcfd;font:15px/1.55 system-ui,-apple-system,Segoe UI,sans-serif"><b style="font-size:1.05rem">Get the full rule table as a PDF</b><div style="opacity:.78;margin:.35rem 0 .8rem">All 15 rules ERB Escape Audit - Rails XSS lint checks, each with its source and date, on printable pages you can hand to your team or auditor. We email it now, and once more only if a rule changes. No newsletter.</div><div style="display:flex;gap:8px;flex-wrap:wrap"><input id="yjae" type="email" placeholder="you@example.com" style="flex:1;min-width:210px;padding:11px 13px;border:1px solid rgba(140,150,165,.34);border-radius:8px;background:#fff;color:inherit"><button id="yjab" style="padding:11px 18px;border-radius:8px;border:0;cursor:pointer;background:#0f7b3f;color:#fff;font:600 15px system-ui,sans-serif">Email me the PDF</button></div><div id="yjao" hidden style="margin-top:.6rem;color:#0f7b3f">Sent. It arrives within the hour - check spam too.</div><script>(function(){var b=document.getElementById("yjab"),e=document.getElementById("yjae");if(!b||!e)return;b.addEventListener("click",function(){var v=e.value.trim();if(!/.+@.+\..+/.test(v))return;try{localStorage.setItem("yj_em","1");}catch(_){}fetch(location.origin+"/api/subscribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:v,slug:"erb-escape-audit",lang:"en",source:"rule_table"+((b.closest&&b.closest("section")&&b.closest("section").getAttribute("data-yj-moved"))?"_at_result":"")})}).then(function(r){var o=document.getElementById("yjao");o.hidden=false;if(!r.ok){o.textContent="Could not save - please try again.";}}).catch(function(){var o=document.getElementById("yjao");o.hidden=false;o.textContent="Could not save - please try again.";});});})();</script><script>(function(){var e=document.getElementById("yjae"),s=e&&e.closest?e.closest("section"):null,o=document.getElementById("out");if(!s||!o)return;var d=false;function mv(ev){if(d)return;if(ev&&ev.target&&s.contains(ev.target))return;d=true;var b=o;while(b.parentNode&&b.parentNode!==document.body&&b.parentNode.tagName!=="MAIN"&&b.parentNode.tagName!=="ARTICLE")b=b.parentNode;if(!b.parentNode||b===s||b.contains(s))return;try{b.parentNode.insertBefore(s,b.nextSibling);s.setAttribute("data-yj-moved","1");}catch(_){}}document.addEventListener("input",mv,true);document.addEventListener("change",mv,true);})();</script></section>
<section data-yj-cta="paid" style="margin:3rem auto;max-width:46rem;padding:1.5rem 1.25rem;border:1px solid #d8dee9;border-radius:12px;background:#fbfcfe;font:16px/1.6 system-ui,-apple-system,Segoe UI,sans-serif"><h2 style="margin:0 0 .5rem;font-size:1.25rem">Get the complete version <span class="yj-price">$29</span></h2><p style="margin:.25rem 0 1rem;color:#41505f">This page is the working piece. The full pack has everything below.</p><p style="margin:0 0 1rem;color:#41505f">15 rules that find every line in a Rails .erb view where output escaping was switched off or was the wrong escaping.</p><p style="margin:0 0 1rem;color:#5b6b7c;font-size:.94rem">A freelance Rails developer doing the same template review by hand bills $75-$150 an hour.</p><a href="https://getreadystack.com/api/buy/cl/polar_cl_XWEOA6J245vtVZGR660GkeoZbO1MW3g23VZSp3goB02" rel="nofollow" style="display:inline-block;padding:.7rem 1.3rem;border-radius:8px;background:#1b64f2;color:#fff;text-decoration:none;font-weight:600">Buy the full version — $29</a></section>
<!--yj-share-s101--><div style="margin:28px 0 8px;text-align:center"><button id="yjcp" type="button" style="font:inherit;padding:10px 18px;border:1px solid #8884;border-radius:10px;background:transparent;cursor:pointer">Copy link</button><button id="yjimg" type="button" style="font:inherit;padding:10px 18px;margin-left:8px;border:1px solid #8884;border-radius:10px;background:transparent;cursor:pointer">Save result as image</button></div><script>(function(){var b=document.getElementById("yjimg");if(!b)return;b.addEventListener("click",function(){var o=document.getElementById("out");var txt=(o&&(o.innerText||o.textContent)||"").trim();if(!txt){b.textContent="(no result yet)";setTimeout(function(){b.textContent="Save result as image";},1500);return;}var lines=txt.split(String.fromCharCode(10)).filter(function(x){return x.trim();}).slice(0,10);var W=1200,P=56,c=document.createElement("canvas");c.width=W;var lh=38,H=P*2+70+lines.length*lh+60;c.height=H;var x=c.getContext("2d");x.fillStyle="#1a1512";x.fillRect(0,0,W,H);x.fillStyle="#f3e9d2";x.font="600 34px system-ui,Segoe UI,Arial";var t=(document.title||"").split("|")[0].trim();x.fillText(t.slice(0,54),P,P+34);x.font="22px ui-monospace,Menlo,Consolas,monospace";x.fillStyle="#e8dcc4";for(var i=0;i<lines.length;i++){x.fillText(lines[i].slice(0,88),P,P+70+i*lh+22);}x.font="600 22px system-ui,Segoe UI,Arial";x.fillStyle="#c9a227";var d=location.hostname.replace("www.","");x.fillText(d,W-P-x.measureText(d).width,H-P+6);c.toBlob(function(bl){var u=URL.createObjectURL(bl),a=document.createElement("a");a.href=u;a.download=(t||"result").replace(/[^a-z0-9]+/gi,"-").toLowerCase()+"-"+d+".png";document.body.appendChild(a);a.click();setTimeout(function(){URL.revokeObjectURL(u);a.remove();},2000);},"image/png");});})();</script><script>(function(){var b=document.getElementById("yjcp");if(!b)return;var t=b.textContent;b.addEventListener("click",function(){var u=location.href.split("#")[0];function d(){b.textContent="Link copied";setTimeout(function(){b.textContent=t;},1800);}if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(u).then(d,function(){prompt("",u);});}else{prompt("",u);}});})();</script><div class="yj-home-find"><div class="yj-lgbar"><a href="/" class="yj-lg on">EN</a><a href="/de/" class="yj-lg">DE</a><a href="/ja/" class="yj-lg">JA</a><a href="/es/" class="yj-lg">ES</a><a href="/pt/" class="yj-lg">PT</a></div><section class="yj-find"><h2>Find a tool</h2><input id="yjq" type="search" autocomplete="off" placeholder="Type a product name, format or task"><div id="yjr"></div></section><style>.yj-lgbar{position:absolute;top:14px;right:18px;display:flex;gap:6px;z-index:60}.yj-lg{font:600 12px/1 system-ui,sans-serif;padding:6px 9px;border-radius:4px;text-decoration:none;color:#8b96a3;border:1px solid rgba(140,150,165,.28)}.yj-lg:hover{color:#e8eaed;border-color:rgba(160,175,195,.6)}.yj-lg.on{color:#0e1116;background:#cfd6de;border-color:#cfd6de}.yj-find{max-width:760px;margin:34px auto 8px;padding:0 20px}.yj-find h2{font:600 20px/1.3 system-ui,sans-serif;margin:0 0 10px}#yjq{width:100%;box-sizing:border-box;padding:13px 15px;font:400 15.5px system-ui,sans-serif;border:1px solid rgba(140,150,165,.34);border-radius:8px;background:rgba(255,255,255,.04);color:inherit}#yjq:focus{outline:none;border-color:#7FD1C4}#yjr{margin-top:10px;display:flex;flex-direction:column;gap:6px}#yjr a{display:flex;justify-content:space-between;gap:12px;padding:11px 14px;border:1px solid rgba(140,150,165,.22);border-radius:6px;text-decoration:none;font:400 14.5px/1.4 system-ui,sans-serif;color:inherit}#yjr a:hover{border-color:#7FD1C4}#yjr .pr{font:600 13px system-ui,sans-serif;color:#5fbf9a;white-space:nowrap}</style><script>(function(){var q=document.getElementById("yjq"),r=document.getElementById("yjr"),D=null;function load(){if(D)return Promise.resolve(D);return fetch("/search-index.json").then(function(x){return x.json();}).then(function(j){D=j;return j;}).catch(function(){D=[];return D;});}function esc(t){return String(t).replace(/[&<>"]/g,function(c){return {"&":"&","<":"<",">":">",'"':"""}[c];});}function run(){var v=q.value.trim().toLowerCase();if(v.length<2){r.innerHTML="";return;}load().then(function(j){var w=v.split(/\s+/),o=[];for(var i=0;i<j.length&&o.length<12;i++){var t=j[i].t.toLowerCase();var ok=true;for(var k=0;k<w.length;k++){if(t.indexOf(w[k])<0){ok=false;break;}}if(ok)o.push(j[i]);}r.innerHTML=o.length?o.map(function(x){return "<a href=\""+esc(x.u)+"\">"+"<span>"+esc(x.t)+"</span>"+(x.p?"<span class=\"pr\">$"+x.p+"</span>":"")+"</a>";}).join(""):"";});}q.addEventListener("input",run);q.addEventListener("focus",load);})();</script></div>
<script data-yj="yj-view-beacon">(function(){try{var p=location.pathname;var k='yjv:'+p;if(sessionStorage.getItem(k))return;sessionStorage.setItem(k,'1');fetch('/api/ev',{method:'POST',keepalive:true,headers:{'Content-Type':'application/json'},body:JSON.stringify({path:p,lang:"en",ref:(document.referrer||''),src:(function(){try{return (new URLSearchParams(location.search).get('ref')||'').replace(/[^a-z_]/g,'').slice(0,12)}catch(e){return ''}})()})}).catch(function(){});}catch(e){}})();</script>
<script data-yj="yj-use-beacon">(function(){try{var o=document.getElementById('out');if(!o)return;var p=location.pathname,k='yju:'+p,sent=false;function fire(){if(sent||sessionStorage.getItem(k))return;if(!(o.textContent||'').replace(/\s+/g,'').length)return;sent=true;sessionStorage.setItem(k,'1');fetch('/api/ev',{method:'POST',keepalive:true,headers:{'Content-Type':'application/json'},body:JSON.stringify({path:p,lang:"en",t:'use',src:(function(){try{return (new URLSearchParams(location.search).get('ref')||'').replace(/[^a-z_]/g,'').slice(0,12)}catch(e){return ''}})()})}).catch(function(){});}var mo=new MutationObserver(function(){fire();if(sent)mo.disconnect();});mo.observe(o,{childList:true,characterData:true,subtree:true});}catch(e){}})();</script>
<section data-yj="yj-facts-v1" style="max-width:920px;margin:48px auto 0;padding:0 20px;font-family:system-ui,sans-serif"><style>.yj-article p{font:400 15.5px/1.7 system-ui,sans-serif;color:#26313b;margin:0 0 12px;max-width:72ch}.yj-article h2,.yj-article h3,.yj-article h4{font:600 17px/1.35 system-ui,sans-serif;color:#1b1f24;margin:22px 0 8px}.yj-article table{border-collapse:collapse;margin:10px 0 16px;font:14px/1.45 system-ui,sans-serif}.yj-article th,.yj-article td{border:1px solid #e2e6ea;padding:6px 10px;text-align:left}.yj-article th{background:#f4f6f8}.yj-article pre{background:#0f1720;color:#e6edf3;padding:12px 14px;border-radius:8px;overflow:auto;font:13px/1.5 ui-monospace,Menlo,monospace}.yj-article code{font:13px ui-monospace,Menlo,monospace;background:#f1f3f5;padding:1px 4px;border-radius:3px}.yj-article pre code{background:none;padding:0}.yj-article ul,.yj-article ol{font:15px/1.6 system-ui,sans-serif;color:#26313b}</style><p style="font:400 13px/1.5 system-ui,sans-serif;color:#6b7680;margin:0 0 14px"><time datetime="2026-09-14">Updated 2026-09-14</time> · ReadyStack</p><script type="application/ld+json">{"@context":"https://schema.org","@type":"WebPage","dateModified":"2026-09-14","author":{"@type":"Organization","name":"ReadyStack"}}</script><h2 style="font:600 21px/1.3 system-ui,sans-serif;margin:0 0 6px;color:#1b1f24">Worked example</h2><p style="font:400 14.5px/1.5 system-ui,sans-serif;margin:0 0 16px;color:#5b6570">Real numbers from this tool, line by line.</p><div class="yj-article"><p>Fifteen unescaped-output lines in a thirty-two-line Rails product page: that is what a Rails developer gets back the first time they run ERB Escape Audit over a view an assistant helped write. Four of them critical, seven high, four medium.</p>
<p>The awkward part is that ERB is already safe: <code><%= value %></code> HTML-escapes what it prints, and nobody has to remember anything. So every XSS hole in a Rails view is a line where somebody turned the escaping off, or a line where HTML escaping was never the escaping that line needed.</p>
<p>There are only three ways to turn it off, and they all look reasonable in review. <code><%= raw @product.description_html %></code> is what you write when the admin stores rich text and the page renders <code>&lt;p&gt;</code> instead of a paragraph. <code><%= @product.blurb.html_safe %></code> is the same move spelled differently, and <code><%== @announcement.body %></code> is the same move again with nothing left to grep for. All three render correctly, which is why they survive a pull request.</p>
<p>The second family is harder, because nothing was switched off - the value was escaped for the wrong context. <code>var name = "<%= @user.name %>";</code> inside a <code><script></code> block is HTML-escaped and still broken: a quote or a literal <code></script></code> in that name ends the string, or the tag. <code>var cfg = <%= @config.to_json %>;</code> fails the same way, because <code>to_json</code> leaves <code></script></code> intact. <code><a href="<%= @product.homepage %>"></code> is escaped and still clickable as <code>javascript:alert(1)</code> if the column holds that, and <code><img src=<%= @product.image_url %>></code> is escaped and still lets a value containing a space add <code>onerror=</code>.</p>
<p>This is the category linters miss, and the reason is structural: a Ruby linter parses Ruby. It sees the ERB tag, not the HTML wrapped around it - and the HTML is what decides whether the value needs <code>j()</code>, <code>json_escape</code>, a quoted attribute or a scheme check.</p>
<p>ERB Escape Audit reads the template as markup and as Ruby at the same time. Fifteen rules: the three ways to switch escaping off, <code>render inline:</code> (which is template injection rather than XSS), interpolation inside <code><script></code> and inside <code><style></code>, <code>href</code> and <code>on*</code> attributes, unquoted attributes, <code>content_tag</code> with its fourth argument set to false, <code>sanitize()</code> with no <code>tags:</code> allowlist, <code>simple_format(..., sanitize: false)</code>, and a Content-Security-Policy meta tag carrying <code>unsafe-inline</code>. Every finding comes back with a line number, a severity, and the helper that makes the line safe: not "this is unsafe" but "wrap it in <code>j()</code>".</p>
<p>The fixtures ship with the extension, so the numbers are checkable. <code>_fixtures/dirty.erb</code> is a plausible product page in thirty-two lines and returns fifteen findings; <code>_fixtures/clean.erb</code> is the same page rewritten with <code>sanitize(tags:)</code>, route helpers, <code>j()</code>, <code>json_escape</code> and a CSP nonce, and returns zero. Same engine in the editor and on the free web page.</p>
<p>There is also a date on this now. Since 11 September 2026 the EU Cyber Resilience Act has made an actively exploited vulnerability in a product with digital elements a reporting event: an early warning within 24 hours, a full notification within 72 hours, a final report within 14 days. A stored XSS in a customer-facing view is that kind of finding, and the clock starts when somebody outside your team notices it.</p>
<p>Auditing the file you have open is free and finished: every finding, every line, every fix, no cap, no watermark. The licensed half is a different axis - scope and ownership - sweeping every <code>.erb</code> file in the workspace and writing a dated report you keep. A freelance Rails developer doing that review by hand bills $75-$150 an hour, and a mid-sized app has hundreds of these files.</p></div><h2 style="font:600 21px/1.3 system-ui,sans-serif;margin:34px 0 6px;color:#1b1f24">Questions people ask</h2><details style="border-bottom:1px solid #e6e9ed;padding:10px 0"><summary style="cursor:pointer;font:600 15px/1.4 system-ui,sans-serif;color:#1b1f24">What does ERB Escape Audit actually do?</summary><p style="margin:8px 0 2px;font:400 14.5px/1.6 system-ui,sans-serif;color:#33404b">It reads Rails .erb templates and reports every line where HTML escaping was switched off or was the wrong escaping for the context: raw, .html_safe, the <%== tag, render inline:, interpolation inside script or style blocks, href and on-event attributes, unquoted attributes, content_tag with escape false, and sanitize without an allowlist. Fifteen rules, each naming the helper that fixes the line.</p></details><details style="border-bottom:1px solid #e6e9ed;padding:10px 0"><summary style="cursor:pointer;font:600 15px/1.4 system-ui,sans-serif;color:#1b1f24">Who is this for?</summary><p style="margin:8px 0 2px;font:400 14.5px/1.6 system-ui,sans-serif;color:#33404b">Rails developers who ship server-rendered views, and the person who reviews those views before a release. It is most useful on codebases where an AI assistant writes template code, because assistants reach for raw and html_safe to make markup render and the result looks correct in the browser either way.</p></details><details style="border-bottom:1px solid #e6e9ed;padding:10px 0"><summary style="cursor:pointer;font:600 15px/1.4 system-ui,sans-serif;color:#1b1f24">Why is a free linter not enough?</summary><p style="margin:8px 0 2px;font:400 14.5px/1.6 system-ui,sans-serif;color:#33404b">Ruby linters parse Ruby, so they see the ERB tag but not the HTML around it. Whether a value needs j, json_escape, a quoted attribute or a scheme check depends on that surrounding markup. Static scanners that do know Rails flag raw and html_safe but not an unquoted src attribute or interpolation inside a style block.</p></details><details style="border-bottom:1px solid #e6e9ed;padding:10px 0"><summary style="cursor:pointer;font:600 15px/1.4 system-ui,sans-serif;color:#1b1f24">What is free and what needs a licence?</summary><p style="margin:8px 0 2px;font:400 14.5px/1.6 system-ui,sans-serif;color:#33404b">Auditing the file you have open is free and complete: every finding, every line number, every fix, no cap and no watermark. The licence covers a different axis, scope and ownership. It sweeps every .erb file in the workspace in one run and writes a dated Markdown or CSV report you keep as evidence.</p></details><details style="border-bottom:1px solid #e6e9ed;padding:10px 0"><summary style="cursor:pointer;font:600 15px/1.4 system-ui,sans-serif;color:#1b1f24">What would this cost to do by hand?</summary><p style="margin:8px 0 2px;font:400 14.5px/1.6 system-ui,sans-serif;color:#33404b">A freelance Rails developer doing the same template review bills $75 to $150 an hour on the open market, and a mid-sized app has hundreds of .erb files. The extension is $29 once, one licence key per person or CI seat,.</p></details><details style="border-bottom:1px solid #e6e9ed;padding:10px 0"><summary style="cursor:pointer;font:600 15px/1.4 system-ui,sans-serif;color:#1b1f24">Why not just ask ChatGPT or another AI chat?</summary><p style="margin:8px 0 2px;font:400 14.5px/1.6 system-ui,sans-serif;color:#33404b">A general AI chat answers from training data with a cutoff date, cannot read your repository and names no rule version. ERB Escape Audit - Rails XSS lint checks the file you open against 15 rules from a rule set dated 2026-09-20, and points at the exact line. For a filing, an audit or a client you need that dated result on your own files.</p></details><script type="application/ld+json" data-yj="yj-faq-ld">{"@context": "https://schema.org", "@type": "FAQPage", "mainEntity": [{"@type": "Question", "name": "What does ERB Escape Audit actually do?", "acceptedAnswer": {"@type": "Answer", "text": "It reads Rails .erb templates and reports every line where HTML escaping was switched off or was the wrong escaping for the context: raw, .html_safe, the <%== tag, render inline:, interpolation inside script or style blocks, href and on-event attributes, unquoted attributes, content_tag with escape false, and sanitize without an allowlist. Fifteen rules, each naming the helper that fixes the line."}}, {"@type": "Question", "name": "Who is this for?", "acceptedAnswer": {"@type": "Answer", "text": "Rails developers who ship server-rendered views, and the person who reviews those views before a release. It is most useful on codebases where an AI assistant writes template code, because assistants reach for raw and html_safe to make markup render and the result looks correct in the browser either way."}}, {"@type": "Question", "name": "Why is a free linter not enough?", "acceptedAnswer": {"@type": "Answer", "text": "Ruby linters parse Ruby, so they see the ERB tag but not the HTML around it. Whether a value needs j, json_escape, a quoted attribute or a scheme check depends on that surrounding markup. Static scanners that do know Rails flag raw and html_safe but not an unquoted src attribute or interpolation inside a style block."}}, {"@type": "Question", "name": "What is free and what needs a licence?", "acceptedAnswer": {"@type": "Answer", "text": "Auditing the file you have open is free and complete: every finding, every line number, every fix, no cap and no watermark. The licence covers a different axis, scope and ownership. It sweeps every .erb file in the workspace in one run and writes a dated Markdown or CSV report you keep as evidence."}}, {"@type": "Question", "name": "What would this cost to do by hand?", "acceptedAnswer": {"@type": "Answer", "text": "A freelance Rails developer doing the same template review bills $75 to $150 an hour on the open market, and a mid-sized app has hundreds of .erb files. The extension is $29 once, one licence key per person or CI seat,."}}, {"@type": "Question", "name": "Why not just ask ChatGPT or another AI chat?", "acceptedAnswer": {"@type": "Answer", "text": "A general AI chat answers from training data with a cutoff date, cannot read your repository and names no rule version. ERB Escape Audit - Rails XSS lint checks the file you open against 15 rules from a rule set dated 2026-09-20, and points at the exact line. For a filing, an audit or a client you need that dated result on your own files."}}]}</script><div id="yj-ask" style="margin:36px 0 0;padding:18px 18px 16px;border:1px solid #e2e6ea;border-radius:10px;background:#fbfbfc"><h2 style="font:600 19px/1.3 system-ui,sans-serif;margin:0 0 4px;color:#1b1f24">Ask about this tool</h2><p style="font:400 13.5px/1.5 system-ui,sans-serif;margin:0 0 12px;color:#5b6570">One question, answered by the person who built it. Your email only if you want the answer sent.</p><form onsubmit="return yjAsk(event)" style="display:grid;gap:8px"><textarea name="question" required minlength="10" maxlength="2000" rows="3" placeholder="Your question" style="font:14px/1.5 system-ui,sans-serif;padding:10px;border:1px solid #cfd6dd;border-radius:6px"></textarea><div style="display:flex;gap:8px;flex-wrap:wrap"><input type="email" name="email" placeholder="Email (optional)" style="flex:1;min-width:200px;font:14px system-ui,sans-serif;padding:9px 10px;border:1px solid #cfd6dd;border-radius:6px"><button type="submit" style="font:600 14px system-ui,sans-serif;padding:9px 18px;border:0;border-radius:6px;background:#1b1f24;color:#fff;cursor:pointer">Send</button></div><p id="yj-ask-msg" style="font:13px system-ui,sans-serif;margin:2px 0 0;color:#0a6238;min-height:1em"></p></form><script>function yjAsk(e){e.preventDefault();var f=e.target,m=document.getElementById("yj-ask-msg");m.style.color="#0a6238";fetch(location.origin+"/api/ask",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sku:58203,slug:"erb-escape-audit",lang:"en",question:f.question.value,email:f.email.value,page:location.pathname})}).then(function(r){if(!r.ok)throw 0;m.textContent="Thanks \u2014 noted. Answers land on this page and, if you left an email, in your inbox.";f.question.value="";}).catch(function(){m.style.color="#a8551f";m.textContent="Could not send. Please try again in a minute.";});return false;}</script></div></section><section class="yj-related" style="max-width:920px;margin:56px auto 40px;padding:0 20px"><h2 style="font:600 21px/1.3 system-ui,sans-serif;margin:0 0 6px;color:#1b1f24">Same job, next tool</h2><p style="font:400 14.5px/1.5 system-ui,sans-serif;margin:0 0 20px;color:#5b6570">Free in the browser. The full version is a one-time licence.</p><div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:14px"><a class="yj-rel-c" href="https://getreadystack.com/tools/firestore-rules-guard"><span class="yj-rel-t">Firestore Rules Guard</span><span class="yj-rel-p">$29</span></a><a class="yj-rel-c" href="https://getreadystack.com/tools/personal-data-map-dsar-audit"><span class="yj-rel-t">Personal Data Map: find every column a GDPR subject access request has to reac</span><span class="yj-rel-p">$29</span></a><a class="yj-rel-c" href="https://getreadystack.com/tools/robots-txt-ai-crawler-audit"><span class="yj-rel-t">AI Crawler Rules - robots.txt Audit for AI Search</span><span class="yj-rel-p">$29</span></a><a class="yj-rel-c" href="https://getreadystack.com/tools/ai-disclosure-lint-sb942"><span class="yj-rel-t">AI Output Disclosure Lint SB 942</span><span class="yj-rel-p">$29</span></a><a class="yj-rel-c" href="https://getreadystack.com/tools/bulk-sender-lint"><span class="yj-rel-t">Bulk Sender Lint - the Gmail, Yahoo and Outlook.com rules, checked in your mai</span><span class="yj-rel-p">$29</span></a><a class="yj-rel-c" href="https://getreadystack.com/tools/csaf-advisory-check-cra-2026"><span class="yj-rel-t">CSAF Advisory Check for CRA 2026</span><span class="yj-rel-p">$29</span></a></div><style>.yj-rel-c{display:flex;flex-direction:column;gap:8px;padding:15px 16px;border:1px solid #e2e6ea;border-radius:6px;text-decoration:none;background:#fff}.yj-rel-c:hover{border-color:#b9c2cb;background:#fafbfc}.yj-rel-t{font:500 14.5px/1.42 system-ui,sans-serif;color:#1b1f24}.yj-rel-p{font:600 13px system-ui,sans-serif;color:#0a6238}</style></section></body>
</html>
|