hopandhaul is a local, single-user tool: hopandhaul-serve binds to 127.0.0.1 and
talks to a fixed set of third-party APIs over hardcoded hosts: Duffel and Geoapify
(optional, keyed) plus four keyless ones, Transitous (api.transitous.org, ground
schedules), Photon (photon.komoot.io, place search), Open-Meteo (api.open-meteo.com,
weather), and frankfurter (api.frankfurter.dev, FX rates). The browser build's CSP
allows exactly two of those (api.transitous.org, photon.komoot.io) beyond 'self',
because the static GitHub Pages app calls them directly; everything else stays
server-side. This document says plainly what that means for security, what's already
handled, and what isn't built yet.
Every outbound HTTP call in this codebase targets a hardcoded host literal: the six
hosts named above, nothing else. No code path builds a request URL or hostname from client input,
a query parameter, or a map click. geo.py (nearest-airport lookup, gateway discovery)
is pure local JSON/math and never touches the network at all.
This is a checkable claim, not a promise: grep src/hopandhaul/*.py and
src/hopandhaul/ui/*.js for http:// and https:// and confirm every hit is a literal
in the source, never an f-string built from a request. If a future feature ever lets a user point the server at their own geocoder or
tile server, that's the point to add the standard SSRF guards (block RFC1918/loopback/
link-local ranges, no blind redirect-following). Don't ship that without them.
serve()bindsThreadingHTTPServerto127.0.0.1only. It never listens on0.0.0.0.- Every request is checked against an
ALLOWED_HOSTSallowlist (127.0.0.1,localhost,::1) before anything else runs, closing the DNS-rebinding attack where a malicious page's JS gets a browser to send a same-origin-looking request to127.0.0.1under a differentHostheader.
If you ever want to run this beyond your own machine (a home server, a shared network),
that is explicitly not what the default stdlib server is built for. http.server's own
docs say it isn't hardened for that: it has no TLS, slow-loris defense, or real request-size
limits. Don't flip the bind to 0.0.0.0 without adding, at minimum: TLS termination (a
reverse proxy is the easy path), real authentication, and rate limiting in front of it.
That's a distinct, opt-in deployment mode this project doesn't build by default.
_resolve_ui_asset() in server.py serves files from the packaged ui/ directory and
nowhere else. Two checks stand between a URL and a file. First the extension has to be on a
short allowlist with a fixed content type for each (.html, .js, .css, .json, images,
fonts and a few more). A .py or .env file never comes back. Then the joined path goes
through os.path.realpath() and has to still sit inside ui/. A .. segment or a symlink
that leads out of ui/ gets a 404 even when the file it reaches has an allowed extension.
The server selftest covers both. /../data/airports.json is a real .json file one level
up, and it is refused.
/api/config returns booleans and provider names only ("duffel" / "photon" /
"geoapify" / null), never a key, never a masked fragment of one. Every other endpoint follows the
same rule. If you add a new endpoint, keep this invariant: nothing that touches
_secrets.get(...) should ever appear in a response body.
Keys themselves resolve through _secrets.py: environment variable first, then a
gitignored secrets.local.json in the package directory. Never commit a real key:
secrets.local.json is in .gitignore, and a secrets.local.example.json with
placeholder values ships instead so you can see the expected shape.
Every endpoint returns one contract: {"ok": true, ...} on success, or
{"ok": false, "error": "<human-readable message>", "code": "<short_code>"} on failure.
Exception details (message, type name, traceback) are logged server-side
(stderr) and never included in the response body. An earlier version of /api/geocode
returned the raw f"{type(e).__name__}: {e}" string to the client; that's fixed, and the
selftest asserts the error shape stays generic so it can't quietly regress.
A shared token bucket sits in front of outbound Duffel calls (2 req/s sustained, small
burst allowance) so a handful of fast map clicks can't blow through Duffel's own account
rate limit. A wall-clock time budget bounds the whole concurrent per-gateway pricing
fan-out inside /api/plan, so one slow upstream degrades that gateway to a distance
estimate instead of holding every request thread open for its full individual timeout.
Neither of these is a defense against a malicious high-volume client. This is a single-user localhost tool with no auth, and that's an intentional scope limit (see above). They exist for reliability against a slow/unstable third-party API, not as a DoS control.
/api/plan, /api/geocode, and /api/nearest validate every query parameter before it
reaches any application logic: numeric fields are type- and range-checked (lat/lng within
real coordinate bounds, traveler count 1-9, threshold and time-budget fields non-negative
with a sane ceiling), string fields are length-capped, and dates are checked against a
real calendar rather than a regex shape. A malformed request gets a 400 with a specific,
safe message, never a stack trace and never a silent wrong answer.
Please don't open a public GitHub issue for a security problem. Use GitHub's private Security Advisory reporting form on this repo, or email the address listed on the maintainer's GitHub profile. Include what you found and how to reproduce it. You don't need a full writeup or a patch.
Never paste a real API key into an issue, PR, or advisory, even a "just testing, revoked already" key. If you think a key of yours leaked, rotate it at the provider first and mention that it happened; don't paste the value anywhere on GitHub.
This is a solo-maintained project. Expect an initial response within a few days, not a contractual SLA, but a real one, and a real fix, not a "thanks, wontfix."
In scope: this repo's server (src/hopandhaul/server.py), key-handling
(src/hopandhaul/_secrets.py), and the fare/geocode/transit/weather-fetching code in
src/hopandhaul/{duffel,geoapify,places,transit,weather,flights}.py.
Out of scope: vulnerabilities in Duffel, Geoapify, Transitous, Photon, Open-Meteo,
frankfurter, or any other upstream provider's own API or infrastructure. Report those to
the provider directly. Also out of scope: the consequences of running hopandhaul-serve
with a manually-flipped bind address or behind your own reverse proxy without the
TLS/auth/rate-limiting called out above, which is a deployment choice this project
explicitly doesn't make for you.