Being in web development for a while, you start exploring different domains than just mastering your favourite framework. I decided to learn more about web security. We hear a lot about preventing our web apps from exploitation, sanitize data, never trust user inputs, so on, so on… the truth is that we overlook (willingly or accidentally) security holes when developing new features, which can lead exploits and production outage.
In this post, I want to explore how an attacker can potentially steal client side information or inject scripts using unvalidated redirects. For this example we will use a dashboard application in Angular, an authentication app in React, and a server in NestJS. In real life you would have a dedicated identity server, just for authentication, but this setup is good enough for our demonstration.
The Scenario
Here is the scenario. Imagine you are in a large organization with multiple production applications. Each app lives on a separate URL, fulfilling a different business purpose. However, you want a centralized login system. You have one authentication endpoint where each user logs in, and the server verifies their identity. Something as showed on the following image:
Expected Behaviour
First I want to describe the expected behaviour (”happy path”) with this approach, how we assume the user should use the app.
- Initial load - You start on the main application (
http://localhost:4200), we use localhost, but the can be any kind of domain name. - Redirect to auth - Since you are not logged in (no token in LocalStorage), the app redirects you to the Auth Server (
http://localhost:4201). - State passing - We send a base64 encoded
statequery parameter (context) to the auth app from the origin. Encoding data as who created the redirection, the origin name (appName), where to return (redirectUrl), and a tracking ID (traceId) for monitoring. - Success - Once authenticated, the user is redirected back to the original app (
http://localhost:4200- information from theredirectUrl), with a JWT token in the query parameter.
The decoded state query param passes to the auth application can be seen in the “debug” green section. When user logs in, back in the original (Angular) app, we grab the JWT token, remove it from the query param and save it into localstorage and to the application internal state. In the GIF above you can see displayed in console this JWT token. This is how it’s done in Angular, just for some overview:
@Component({ /* ... */ })
export class App {
private readonly appState = inject(AppState);
private readonly router = inject(Router);
constructor() {
afterNextRender(() => {
// check URL for token on initial load - user authenticated
const params = new URLSearchParams(window.location.search);
const token = params.get('token') as string | null;
if (!token) {
return;
}
// custom logic to parse the JWT into normal object
const decodedToken = customDecodeToken<User>(token);
if (decodedToken) {
// clean URL to remove token param
window.history.replaceState({}, document.title, '/');
// save the user to the application state
this.appState.setData('token', token);
this.appState.setData('user', decodedToken);
// persist data for application reload
localStorage.setItem('access_token', token);
// redirect to the app
this.router.navigate(['/overview']);
}
});
}
}
Overview Of XSS Vulnerability In Redirection
The application works fine, so where exactly is the potential bug ? We need to take a look at the React (authentication) app. Even if you are not familiar with React, the logic here is straightforward:
import { useEffect, useState } from 'react';
function App() {
// used in HTML to set auth data
const [username, setUsername] = useState(authLogin.username);
const [password, setPassword] = useState(authLogin.password);
// decoded from the URL `state` query param
const [appName, setAppName] = useState('');
const [redirectUrl, setRedirectUrl] = useState('');
const [traceId, setTraceId] = useState('');
useEffect(() => {
// read the 'state' query param
const params = new URLSearchParams(window.location.search);
const stateParam = params.get('state');
if (!stateParam) {
return
}
// decode base64 - vulnerability
const decodedString = atob(stateParam);
const stateData = JSON.parse(decodedString);
// extract context information
if (stateData.appName) {
setAppName(stateData.appName);
}
if (stateData.redirectUrl) {
setRedirectUrl(stateData.redirectUrl);
}
if (stateData.traceId) {
setTraceId(stateData.traceId);
}
}, []);
const handleLogin = async (e: any) => {
e.preventDefault();
try {
// send auth to NestJS backend
const response = await fetch('http://localhost:3000/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
// invalid credentials
if (!response.ok) {
throw new Error('Invalid credentials');
}
const data = await response.json();
const token = data.access_token;
// handle redirect
if (redirectUrl) {
const hasToken = redirectUrl.includes('{token}');
// check if contains the {token} query param, if so replace it or append at the end
const finalUrl = hasToken ? redirectUrl.replace('{token}', token) : `${redirectUrl}?token=${token}`;
// redirect back to original domain
window.location.href = finalUrl;
}
} catch (error) {
alert('Login Failed: Invalid username or password');
console.error(error);
}
};
return ( /* HTML .... */ );
}
export default App;
I wouldn’t personally have known that there was a bug if I hadn’t built this example specifically to test it. The vulnerability is hidden in how we handle the redirect using window.location.href.
This property is used to navigate the user to a different domain. If we set it to window.location.href = "https://google.com", we go to Google. Where is the problem tho? If we do not validate the redirectUrl, it is possible to execute code like this:
window.location.href = "javascript:alert('XSS Executed');window.location.href='https://www.google.com';//"
Now, of course, this demonstration will not work on every domain. If you are outside localhost, you may receive an Error as displayed below. The script execution is prevented by CSP (Content Security Policy). This header tells the browser to only execute code from trusted JS file. For this demonstration, we assume the application has a missing or weak CSP (allowing 'unsafe-inline'), which can happen in legacy apps. Most of the time you have a strict CSP, therefore executing such a script is not that simple. Currently we ignore this defense mechanism, we just want to demonstrate one of the potential vulnerabilities, as the attacker could find a different backdoor to execute the following attack.
Real World Demonstration
Since we are not sanitizing the redirectUrl, that is parsed on the auth React app, we can encode Javascript code inside it. When we visit the auth application, all the information is encoded to the state query param by base64. Therefore we can construct a malicious payload and encode it:
{
"appName": "Workday Clone (Angular)",
"traceId": "Example-123",
"redirectUrl": "javascript:alert('XSS Executed');window.location.href='http://localhost:4200?token={token}'"
}
// will result to the following hash:
/**
ewogICAgICJhcHBOYW1lIjogIldvcmtkYXkgQ2xvbmUgKEFuZ3VsYXIpIiwKICAidHJhY2VJZCI6
ICJFeGFtcGxlLTEyMyIsCiAgInJlZGlyZWN0VXJsIjogImphdmFzY3JpcHQ6YWxlcnQoJ1hTUyBF
eGVjdXRlZCcpO3dpbmRvdy5sb2NhdGlvbi5ocmVmPSdodHRwOi8vbG9jYWxob3N0OjQyMDA/dG9r
ZW49e3Rva2VufSciCn0=
**/
Both application is running on localhost, but the domains can be anything. The idea of this attack is simple:
- The attacker sends a victim a link to the legitimate login page, but with a modified
stateparameter. - The victim logs in securely.
- The React app reads the malicious
redirectUrl. - The browser executes the injected script (XSS).
- The script then redirects the user to the actual app, so the victim has no idea what happened.
On this example you see a harmless alert for a demonstration. But what if we wanted to be destructive? We could inject code in the state query param to steal the user's localStorage, cookies and the JTW token, and send them to an external server using fetch() before redirecting them.
const t = '{token}';
const capturedData = {
cookies: document.cookie,
storage: localStorage,
token: t
};
// attacker would use fetch() to log somewhere this data
alert(JSON.stringify(capturedData, null ,2));
window.location.href = 'http://localhost:4200?token=' + t;
// --------------------
/** -- Generated Base64
ewogICAgICJhcHBOYW1lIjogIldvcmtkYXkgQ2xvbmUgKEFuZ3VsYXIpIiwKICAgICAgInRyYWNl
SWQiOiAic3AtMTIzIiwKICAgICAgInJlZGlyZWN0VXJsIjogImphdmFzY3JpcHQ6Y29uc3QgdD0n
e3Rva2VufSc7Y29uc3QgY2FwdHVyZWREYXRhPXtjb29raWVzOmRvY3VtZW50LmNvb2tpZSxzdG9y
YWdlOiBsb2NhbFN0b3JhZ2UsdG9rZW46dH07YWxlcnQoSlNPTi5zdHJpbmdpZnkoY2FwdHVyZWRE
YXRhLG51bGwsMikpO3dpbmRvdy5sb2NhdGlvbi5ocmVmPSdodHRwOi8vbG9jYWxob3N0OjQyMDA/
dG9rZW49Jyt0OyIKfQ==
**/
In the example above, we successfully displayed (or "stole") client’s information. The user was still able to log in, meaning the attack was executed silently. The attacker now has the user’s valid token and can impersonate them.
A confusing part may be how the attacker got the JWT token. This goes back to the this code redirectUrl.replace('{token}', token). The redirectUrl is the whole JS displayed above as a string:
"redirectUrl": "javascript:const t='{token}';const capturedData={cookies:document.cookie,storage: localStorage,token:t};alert(JSON.stringify(capturedData,null,2));window.location.href='http://localhost:4200?token='+t;"
The attacker knows the replace part exists in the React app. Yes, the JS is minified in PROD, but let’s skip the logic how the attacker knows about this vulnerability. The auth application sees the {token} placeholder and injects the real JWT right into it and then just executes the whole JS inside the redirectUrl.
Keep in mind that sometimes when the query param (state) is too long, it can fail on parsing/decoding. Therefore the attacker could use JS minification and URL shortener tools to avoid parsing crashes and suspicion from the victim side.
Even if our application had a strict CSP that would have blocked the javascript: execution, we would still have a major security hole, an “Open Redirect Vulnerability”. The attacker could set the redirectUrl to http://fake-login-page.com. The user logs inside the real app, the server returns a valid token, and then auth app immediately redirects the use to the attacker's phishing site (which looks the same as our auth app). The victim might re-enter their credentials into the fake site. Therefore we should have an allowlist, list of valid domain where the user can be redirected.
Avoid XSS Vulnerability In Redirection
This demo tried to mimic some real-world scenario, but also it was designed such that we will be able to execute this XSS redirect attack. Production applications are probably much more protected than my demo, but if your app looks like something that I’ve presented, here are some ways how you can prevent this attack:
- Temporary Token - Instead of sending the actual JWT access token in the URL, the auth server should generate a one time use "authorization code" and pass that in the redirect. When the Angular dashboard receives this code, it requests the backend to exchange it for the real JWT. This works because the code is valid only for a few seconds, making it useless even if captured.
- Token Reuse Detection - The backend should mark the real token as "used" in the database immediately after it is exchanged or validated for the first time. You can even attached the requester IP address to the specific token. If the system detects a second attempt to use the same token ("replay attack"), it should immediately invalidate that token… maybe even alert the security team.
-
Domain Validation - The most immediate fix is to validate the
redirectUrlon the client side, sanitize the data. Probably creating one URL validation function that checks if the contains anyjavascript:code and whether it starts withhttp:orhttps:. Even an “allowedList” of trusted domains that sits on the authentication app can help to prevent redirection to any phishing site.
There are many more ways how to prevent this attack. I am not a security expect, but this is an interesting example I bumped into when I was researching about security, so I wanted to share my findings. Hope you liked this article, catch more of my stuff on dev.to, connect with me on LinkedIn or check my Personal Website.







Top comments (0)