Spaces:
Running
Running
Download index.html from getreadystack/erb-escape-audit: direct link, hf CLI and curl.
- Browser
- Download file 42.2 kB
-
https://huggingface.co/spaces/getreadystack/erb-escape-audit/resolve/main/index.html
- Command line
-
hf download hf://spaces/getreadystack/erb-escape-audit/index.html
-
curl -L -o index.html https://huggingface.co/spaces/getreadystack/erb-escape-audit/resolve/main/index.html
42.2 kB
| <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 와 브라우저에서 산다. | |
| ; | |
| 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> | |