Open redirect via startsWith('/') bypass in POST /login
Summary
src/session-auth.js#login accepts a sendTo form field and, after the session is saved, redirects to decodeURIComponent(req.body.sendTo) whenever the value starts with /. The intent of the startsWith('/') guard is to allow only same-origin paths, but it does not actually enforce that — a value such as //example.com/path also starts with / and is treated by browsers as a protocol-relative URL, sending the victim off-origin. Encoded forms (/%2fexample.com/... → //example.com/... after decodeURIComponent) bypass the check the same way.
The endpoint reaches this redirect even when the submitted password is wrong — login calls req.session.save and then redirects regardless of whether req.session.loggedIn was set. So this is exploitable without any valid credentials.
Severity
The classic open-redirect impact is phishing: an attacker hosts https://victim-postmarks.example/login with an auto-submitting form that sets sendTo=//attacker.example/fake-login, the victim's browser sees the redirect coming from the legitimate postmarks origin, then lands on the attacker's site and is invited to log in again. Because postmarks login is single-password (ADMIN_KEY), capturing that password gives the attacker full admin on the instance.
Affected versions
Observed on ckolderup/postmarks main as of 2026-05-13 (last commit 2025-09-09). The vulnerable code path has been present since the sendTo redirect was added in session-auth.js.
Vulnerable code
src/session-auth.js
export function login(req, res, next) {
req.session.regenerate((err) => {
if (err) next(err);
if (req.body.password === process.env.ADMIN_KEY) {
req.session.loggedIn = true;
}
req.session.save((saveErr) => {
if (saveErr) return next(saveErr);
if (req.body.sendTo && req.body.sendTo.startsWith('/')) {
return res.redirect(decodeURIComponent(req.body.sendTo));
}
return res.redirect('/');
});
});
}
Two issues compound here:
startsWith('/') accepts //host, /%2fhost, /\\host. Browsers (Chromium, Firefox, Safari) all resolve //evil.example/x as a protocol-relative URL when used as the Location: header, sending the user to https://evil.example/x (or http:// matching the origin's scheme).
- The redirect runs unconditionally on every login submission — the password check only gates
loggedIn, not the redirect — so the gadget is reachable without authentication.
Reproduction
I confirmed the bypass end-to-end with a real browser (Playwright Chromium) against a minimal local reproduction that copies login verbatim. Test matrix:
sendTo value |
Location header |
Final URL |
Left origin? |
//example.com/postmarks-poc |
//example.com/postmarks-poc |
http://example.com/postmarks-poc |
yes |
/%2fexample.com/postmarks-poc |
//example.com/postmarks-poc (after decodeURIComponent) |
http://example.com/postmarks-poc |
yes |
/users/admin (control, same-origin path) |
/users/admin |
http://victim/users/admin |
no (correct) |
https://example.com/... (control, absolute URL) |
/ |
http://victim/ |
no (correctly rejected) |
Attacker page that drives it:
<!doctype html>
<form id=f method=POST action="https://victim-postmarks.example/login">
<input name=password value=wrong>
<input name=sendTo value="//attacker.example/fake-login">
</form>
<script>document.getElementById('f').submit();</script>
Victim's browser silently lands on https://attacker.example/fake-login after a brief stop at victim-postmarks.example.
Impact
- Phishing / credential capture. Attacker hosts a clone of the postmarks login page on
attacker.example. The redirect coming from the real victim-postmarks.example makes the lure feel legitimate.
- OAuth-style abuse, where applicable. If a postmarks instance is ever integrated with another service that whitelists postmarks's domain for redirect URIs, this turns into a redirect-uri laundering primitive.
- Reputation / SSRF on the user. Any URL the attacker controls is a one-hop away from the postmarks origin in the user's history and referrer chain.
Suggested fix
Validate that sendTo is strictly a same-origin path. The minimum bar is "must start with / and the second character must not be / or \\".
- if (req.body.sendTo && req.body.sendTo.startsWith('/')) {
- return res.redirect(decodeURIComponent(req.body.sendTo));
- }
+ const sendTo = req.body.sendTo;
+ if (
+ typeof sendTo === 'string' &&
+ sendTo.startsWith('/') &&
+ !sendTo.startsWith('//') &&
+ !sendTo.startsWith('/\\')
+ ) {
+ // Decode after the safety check so encoded "//" / "/\\" cannot slip through.
+ const decoded = decodeURIComponent(sendTo);
+ if (decoded.startsWith('/') && !decoded.startsWith('//') && !decoded.startsWith('/\\')) {
+ return res.redirect(decoded);
+ }
+ }
return res.redirect('/');
Stronger alternative: parse the value with new URL(sendTo, req.headers.host ? 'https://' + req.headers.host : 'http://localhost') and require the resulting origin to match the request's own origin before extracting pathname + search + hash.
Defence-in-depth
- Decode-then-validate ordering: as written, validation runs on the encoded form and decoding happens after, which is the cause of the
/%2fexample.com bypass. Either decode first and re-validate, or block any encoded slashes in the input.
- Consider only running the redirect when the password actually matched (
req.session.loggedIn === true). The current code redirects even on failed logins, which is unusual and slightly enlarges the attack surface for related gadgets.
Credit
Reported by @EdamAme-x (GitHub).
Open redirect via
startsWith('/')bypass inPOST /loginSummary
src/session-auth.js#loginaccepts asendToform field and, after the session is saved, redirects todecodeURIComponent(req.body.sendTo)whenever the value starts with/. The intent of thestartsWith('/')guard is to allow only same-origin paths, but it does not actually enforce that — a value such as//example.com/pathalso starts with/and is treated by browsers as a protocol-relative URL, sending the victim off-origin. Encoded forms (/%2fexample.com/...→//example.com/...afterdecodeURIComponent) bypass the check the same way.The endpoint reaches this redirect even when the submitted password is wrong —
logincallsreq.session.saveand then redirects regardless of whetherreq.session.loggedInwas set. So this is exploitable without any valid credentials.Severity
AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:N/A:NThe classic open-redirect impact is phishing: an attacker hosts
https://victim-postmarks.example/loginwith an auto-submitting form that setssendTo=//attacker.example/fake-login, the victim's browser sees the redirect coming from the legitimate postmarks origin, then lands on the attacker's site and is invited to log in again. Because postmarks login is single-password (ADMIN_KEY), capturing that password gives the attacker full admin on the instance.Affected versions
Observed on
ckolderup/postmarksmainas of 2026-05-13 (last commit 2025-09-09). The vulnerable code path has been present since thesendToredirect was added insession-auth.js.Vulnerable code
src/session-auth.jsTwo issues compound here:
startsWith('/')accepts//host,/%2fhost,/\\host. Browsers (Chromium, Firefox, Safari) all resolve//evil.example/xas a protocol-relative URL when used as theLocation:header, sending the user tohttps://evil.example/x(orhttp://matching the origin's scheme).loggedIn, not the redirect — so the gadget is reachable without authentication.Reproduction
I confirmed the bypass end-to-end with a real browser (Playwright Chromium) against a minimal local reproduction that copies
loginverbatim. Test matrix:sendTovalueLocationheader//example.com/postmarks-poc//example.com/postmarks-pochttp://example.com/postmarks-poc/%2fexample.com/postmarks-poc//example.com/postmarks-poc(afterdecodeURIComponent)http://example.com/postmarks-poc/users/admin(control, same-origin path)/users/adminhttp://victim/users/adminhttps://example.com/...(control, absolute URL)/http://victim/Attacker page that drives it:
Victim's browser silently lands on
https://attacker.example/fake-loginafter a brief stop atvictim-postmarks.example.Impact
attacker.example. The redirect coming from the realvictim-postmarks.examplemakes the lure feel legitimate.Suggested fix
Validate that
sendTois strictly a same-origin path. The minimum bar is "must start with/and the second character must not be/or\\".Stronger alternative: parse the value with
new URL(sendTo, req.headers.host ? 'https://' + req.headers.host : 'http://localhost')and require the resultingoriginto match the request's own origin before extractingpathname + search + hash.Defence-in-depth
/%2fexample.combypass. Either decode first and re-validate, or block any encoded slashes in the input.req.session.loggedIn === true). The current code redirects even on failed logins, which is unusual and slightly enlarges the attack surface for related gadgets.Credit
Reported by
@EdamAme-x(GitHub).