diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ea5ffed1..399a2a5e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ ## [Unreleased] +## [1.29.5] — 2026-07-17 + +### Security + +- **Hardened the same-origin redirect guard.** A crafted relative + post-login `next` path (e.g. `/..//evil.com`) passed the same-origin + check but left a protocol-relative pathname that could re-resolve to a + foreign origin when re-used in a redirect or client navigation. The + guard now re-verifies the reconstructed path resolves on-origin and + rejects it otherwise. Covers the OIDC callback and the password/passkey + login redirect. + +### Fixed + +- **OIDC login redirects use the operator app URL, not the request URL.** + Behind a reverse proxy that does not forward the original host, the + login and callback routes built their browser redirects from the + address the app was reached at inside the container, sending users to + an unreachable internal URL after login. Every OIDC redirect is now + built from the operator-set `NEXT_PUBLIC_APP_URL`, matching the + convention the other integrations already use. + ## [1.29.4] — 2026-07-17 - **The workouts list shows the most recent first.** The list was ordered diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index db9fade75..183eb5ea8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.29.4 + version: 1.29.5 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 068f91e17..371f4d0b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.29.4", + "version": "1.29.5", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index bbd60981a..d91e5e259 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.29.4"; + /* @sw-version-fallback */ "v1.29.5"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/lib/__tests__/url-safety.test.ts b/src/lib/__tests__/url-safety.test.ts index d94474fe5..a97c04c03 100644 --- a/src/lib/__tests__/url-safety.test.ts +++ b/src/lib/__tests__/url-safety.test.ts @@ -31,6 +31,23 @@ describe("sanitizeSameOriginPath", () => { expect(sanitizeSameOriginPath("\\\\evil.com", BASE)).toBe("/"); }); + it("rejects a same-origin next whose pathname collapses to protocol-relative", () => { + // `/..//evil.com` resolves ON-origin here (the `..` collapses at root), + // but its pathname is `//evil.com`. Consumers re-resolve the returned + // value (`new URL(result, base)`, `router.push`), where `//evil.com` + // becomes protocol-relative and escapes to https://evil.com. The + // reconstructed-path re-check must reject these to "/". + expect(sanitizeSameOriginPath("/..//evil.com", BASE)).toBe("/"); + expect(sanitizeSameOriginPath("/.//evil.com", BASE)).toBe("/"); + expect(sanitizeSameOriginPath("/../..//evil.com", BASE)).toBe("/"); + }); + + it("still allows a legitimate same-origin path with an inner double slash", () => { + // Only a LEADING `//` (protocol-relative) escapes on re-resolve; a double + // slash inside the path stays on-origin and must be preserved. + expect(sanitizeSameOriginPath("/reports/a//b", BASE)).toBe("/reports/a//b"); + }); + it("preserves a same-origin path's search and hash", () => { expect(sanitizeSameOriginPath("/settings?tab=security#top", BASE)).toBe( "/settings?tab=security#top", diff --git a/src/lib/url-safety.ts b/src/lib/url-safety.ts index b8422e75d..3d0be7bbf 100644 --- a/src/lib/url-safety.ts +++ b/src/lib/url-safety.ts @@ -22,10 +22,19 @@ export function sanitizeSameOriginPath( ): string { if (!next) return "/"; try { - const resolved = new URL(next, baseUrl); const base = new URL(baseUrl); + const resolved = new URL(next, baseUrl); if (resolved.origin !== base.origin) return "/"; - return `${resolved.pathname}${resolved.search}${resolved.hash}`; + const result = `${resolved.pathname}${resolved.search}${resolved.hash}`; + // Consumers commonly RE-RESOLVE the returned value against the origin + // (`new URL(result, base)` in the OIDC callback redirect, `router.push()` + // on the login page). A same-origin `next` can still leave a pathname + // like `//evil.com` — e.g. `/..//evil.com` resolves on-origin here (the + // `..` collapses at root) yet its pathname is protocol-relative, so the + // re-resolve escapes to `https://evil.com`. Re-verify that the + // reconstructed path itself still resolves on-origin; reject otherwise. + if (new URL(result, base).origin !== base.origin) return "/"; + return result; } catch { return "/"; }