Server rendered applications, PHP, Rails, older ASP.NET, and similar setups that build HTML on the server and add jQuery style JavaScript on top, build up a particular kind of security debt over the years. Inline click handlers, timer calls that act like eval, and raw HTML being inserted straight into the page, all working fine until a strict security policy or an actual review forces the question of whether any of it was ever actually safe. None of this needs a full framework rewrite to fix. It needs a small set of steady patterns, used everywhere. Here they are, shown as plain before and after code.
<!-- BAD. Inline handler, blocked by a strict policy. The argument is built from a
string, which is an actual risk the moment itemId is not escaped properly. -->
<button onclick="cart.removeItem('<?= $itemId ?>')">Remove</button>
<!-- GOOD. No inline JavaScript at all, just a data attribute and an escaped value. -->
<button
data-action="cart.removeItem"
data-action-arg="<?= htmlspecialchars($itemId, ENT_QUOTES, 'UTF-8') ?>"
>Remove</button>
// One shared handler, set up once, works for every button on the page,
// including ones added later through an AJAX call.
document.body.addEventListener('click', function (e) {
var el = e.target.closest('[data-action]');
if (!el) return;
AppSecurity.safeInvoke(el.dataset.action, el.dataset.actionArg);
});
The win here is not only that this passes a strict policy. The function name and its argument are now two separate values, each escaped on its own, instead of one joined string. That closes off a whole family of bugs that start with what happens if this value contains a quote.
// BAD. The server tells the browser what to run, and the browser just runs it.
eval(callbackName + '()');
window[callbackName]();
// GOOD. The browser keeps its own fixed, reviewed list of what it is willing to call.
const AppSecurity = (function () {
const registry = {};
return {
register(name, fn) { registry[name] = fn; },
safeInvoke(name, arg) {
if (typeof registry[name] === 'function') registry[name](arg);
},
};
})();
AppSecurity.register('cart.removeItem', function (id) { cart.removeItem(id); });
A response from the server can ask to call cart.removeItem all it wants. If that name was never registered on the browser side, nothing happens. What the browser is allowed to do is decided once, in its own code, not by whatever happens to arrive in a response.
// BAD. Whatever the server sends becomes live page content, scripts included.
document.querySelector('#panel').innerHTML = response.html;
// GOOD. Every piece of HTML from the server passes through one cleaning function.
SafeHTML.set(document.querySelector('#panel'), response.html);
This cleaning function reads the string, removes anything dangerous, frames pointing outside the site, embedded objects, javascript style links, handlers that were never registered, and only then places it on the page. The important part is that there is exactly one place in the whole codebase that touches the page with server sent HTML. Every other part of the code goes through it, so fixing the cleaner fixes every single place at once.
// BAD. A string passed to setTimeout runs like eval, quietly, and a strict
// policy that blocks eval will simply break this outright.
setTimeout("submitSearch()", 500);
// GOOD. Pass an actual function, never a string. No hidden eval, ever.
setTimeout(function () { submitSearch(); }, 500);
This one is easy to miss in a review because it looks like normal code at a glance. The sign to watch for is the quotes. Any timer call whose first piece is a string is worth a second look.
// BAD. Goes wherever the server says, even if that value came from an attacker.
window.location = response.redirectUrl;
// GOOD. Check the destination is actually your own site before moving the page there.
function isSameOrigin(url) {
try { return new URL(url, location.href).origin === location.origin; }
catch (e) { return false; }
}
if (isSameOrigin(response.redirectUrl)) {
window.location = response.redirectUrl;
}
Open redirects show up often in security reviews exactly because this check is so easy to skip when the redirect obviously comes from your own backend, right up until that backend is simply repeating a value that came from a web address three steps further up the chain.
// BAD. Binds to today's elements. Quietly stops working the second time this
// part of the page is replaced through AJAX, since those elements are gone.
document.querySelectorAll('#form select').forEach(function (el) {
el.addEventListener('change', submitFilter);
});
// GOOD. Bind once, to a parent element that is never replaced.
if (!window.__filterBound) {
window.__filterBound = true;
document.addEventListener('change', function (e) {
if (e.target.closest('#form select')) submitFilter();
});
}
This particular bug only shows up in apps that reload pieces of a page through AJAX rather than fully changing pages, and it is one of the more annoying ones to track down, since the feature works perfectly the first time and quietly breaks the second.
Every good example above follows the same shape. Keep the data separate from the code that runs, and send both through one shared, reviewed path, instead of letting each page or script invent its own version. That single habit is what makes a strict security policy possible to adopt, and it is worth doing whether or not you are enforcing such a policy yet. It is simply fewer ways for input you do not control to turn into code that runs.
Maybeach Tech helps engineering teams bring safe JavaScript patterns into server rendered applications without a full framework rewrite. Get in touch and let us look at your templates.