Skip to content

Open redirect in POST /login via startsWith('/') bypass for protocol-relative URLs

Moderate
ckolderup published GHSA-g7cm-xqgg-9w8x May 15, 2026

Package

npm postmarks (npm)

Affected versions

<= 0.0.1

Patched versions

None

Description

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

Metric Value
CVSS 3.1 vector AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:N/A:N
CVSS 3.1 score 4.7 (Medium)
CWE CWE-601: URL Redirection to Untrusted Site ('Open Redirect')

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:

  1. 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).
  2. 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

  1. 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.
  2. 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.
  3. 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).

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
Required
Scope
Changed
Confidentiality
Low
Integrity
None
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:N/A:N

CVE ID

No known CVE

Weaknesses

URL Redirection to Untrusted Site ('Open Redirect')

The web application accepts a user-controlled input that specifies a link to an external site, and uses that link in a redirect. Learn more on MITRE.

Credits