Skip to content

joinURL drops trailing slash when path is "/", breaks proxying to apps mounted on a sub-path #134

@TimoStolz

Description

@TimoStolz

Environment

  • httpxy@0.5.0
  • Node.js (any recent)

Summary

When the proxy target has a non-root pathname (e.g. http://localhost:1080/maildev) and the incoming request URL is exactly /, the resulting outgoing path is /maildev — without the trailing slash. Any upstream that distinguishes /maildev from /maildev/ (e.g. Express apps mounted with app.use('/maildev', ...), which 301-redirect the no-slash form to the slash form) ends up in an infinite redirect loop when fronted by httpxy.

Where

src/_utils.ts (compiled dist/index.mjs:112-119):

function joinURL(base, path) {
  if (!base || base === "/") return path || "/";
  if (!path || path === "/") return base || "/";  // <-- drops the slash
  const baseHasTrailing = base[base.length - 1] === "/";
  const pathHasLeading = path[0] === "/";
  if (baseHasTrailing && pathHasLeading) return base + path.slice(1);
  if (!baseHasTrailing && !pathHasLeading) return base + "/" + path;
  return base + path;
}

The early-return on line 2 conflates no path with root path. In URL semantics they aren't equivalent: /maildev and /maildev/ are different resources, and a properly written reverse proxy should preserve the trailing slash.

For comparison, the canonical http-proxy (which httpxy is a rewrite of) concatenates target.pathname + req.url directly, so /maildev + //maildev/. The current behavior is a divergence from that reference implementation.

Why this matters in practice

This is hit any time httpxy sits in front of a typical sub-path-mounted app and the user navigates to the index of that mount. h3's app.use(prefix, handler) strips the matched prefix from req.url, so a request to /maildev/ arrives at httpxy with req.url === '/' — exactly the case that triggers the bug. (Same effect for nitro's devProxy, which is what brought me here.)

A concrete real-world example is MailDev's middleware integration (https://deepwiki.com/maildev/maildev/3-usage-guide#4-middleware-integration), which sets a basePathname and 301-redirects the no-slash form.

Minimal repro

import { createProxyServer } from 'httpxy'
import { createServer } from 'node:http'

// Upstream that 301s /foo to /foo/ (mimics Express sub-path mount)
const upstream = createServer((req, res) => {
  if (req.url === '/foo')  { res.writeHead(301, { location: '/foo/' }); return res.end() }
  if (req.url === '/foo/') { res.writeHead(200); return res.end('ok') }
  res.writeHead(404); res.end()
}).listen(4001)

const proxy = createProxyServer({ target: 'http://localhost:4001/foo' })

createServer((req, res) => {
  // simulate prefix-stripping middleware: incoming /foo/ has been rewritten to /
  req.url = '/'
  proxy.web(req, res)
}).listen(4000)

// curl -i http://localhost:4000/
//   actual:   301 Location: /foo/   -> redirect loop
//   expected: 200 ok

Suggested fix

Replace the lossy early-return with a concatenation that preserves the slash:

function joinURL(base, path) {
  if (!base) return path || '/'
  if (!path) return base
  const baseHasTrailing = base[base.length - 1] === '/'
  const pathHasLeading  = path[0] === '/'
  if (baseHasTrailing && pathHasLeading) return base + path.slice(1)
  if (!baseHasTrailing && !pathHasLeading) return base + '/' + path
  return base + path
}

With this, joinURL('/maildev', '/') returns '/maildev/', matching http-proxy and fixing the loop.

I'm happy to send a PR if the maintainers agree on the direction.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions