Średnia

h3: Double Decoding in `serveStatic` Bypasses `resolveDotSegments` Path Traversal Protection via `%252e%252e`

  • Ujawniono: 20.03.2026
  • Aktualizacja: 11.10.2026 10:22

Summary

The serveStatic utility in h3 applies a redundant decodeURI() call to the request pathname after H3Event has already performed percent-decoding with %25 preservation. This double decoding converts %252e%252e into %2e%2e, which bypasses resolveDotSegments() (since it checks for literal . characters, not percent-encoded equivalents). When the resulting asset ID is resolved by URL-based backends (CDN, S3, object storage), %2e%2e is interpreted as .. per the URL Standard, enabling path traversal to read arbitrary files from the backend.

Details

The vulnerability is a conflict between two decoding stages:

Stage 1 — H3Event constructor (src/event.ts:65-69):

ts
if (url.pathname.includes("%")) {
  url.pathname = decodeURI(
    url.pathname.includes("%25") ? url.pathname.replace(/%25/g, "%2525") : url.pathname,
  );
}

This correctly preserves %25 sequences by escaping them before decoding. A request for /%252e%252e/etc/passwd produces event.url.pathname = /%2e%2e/etc/passwd — the %25 was preserved so %252e became %2e (not .).

Stage 2 — serveStatic (src/utils/static.ts:86-88):

ts
const originalId = resolveDotSegments(
  decodeURI(withLeadingSlash(withoutTrailingSlash(event.url.pathname))),
);

This applies a second decodeURI(), which decodes %2e → ., producing /../../../etc/passwd. However, the decoding happens inside the resolveDotSegments() call argument — decodeURI runs first, then resolveDotSegments processes the result.

Wait — re-examining the flow more carefully:

  1. Input pathname after event.ts: /%2e%2e/%2e%2e/etc/passwd
  2. decodeURI() in static.ts converts %2e → ., producing: /../../../etc/passwd
  3. resolveDotSegments("/../../../etc/passwd") does resolve .. segments, clamping to /etc/passwd

The actual bypass is subtler. decodeURI() does not decode %2e — it only decodes characters that encodeURI would encode. Since . is never encoded by encodeURI, %2e is not decoded by decodeURI(). So the chain is:

  1. Request: /%252e%252e/%252e%252e/etc/passwd
  2. After event.ts decode: /%2e%2e/%2e%2e/etc/passwd
  3. decodeURI() in static.ts: /%2e%2e/%2e%2e/etc/passwd (unchanged — decodeURI doesn't decode %2e)
  4. resolveDotSegments() fast-returns at line 56 because %2e contains no literal . character:
    ts
    if (!path.includes(".")) {
      return path;
    }
  5. Asset ID /%2e%2e/%2e%2e/etc/passwd is passed to getMeta() and getContents() callbacks
  6. URL-based backends resolve %2e%2e as .. per RFC 3986 / URL Standard

The root cause is resolveDotSegments() only checks for literal . characters and does not account for percent-encoded dot sequences (%2e). The decodeURI() in static.ts is redundant (event.ts already decodes) but is not the direct cause — the real gap is that %2e%2e survives as a traversal payload through both decoding stages and resolveDotSegments.

PoC

1. Create a minimal h3 server with a URL-based static backend:

js
// server.mjs
import { H3, serveStatic } from "h3";
import { serve } from "srvx";

const app = new H3();

app.get("/**", (event) => {
  return serveStatic(event, {
    getMeta(id) {
      console.log("[getMeta] asset ID:", id);
      // Simulate URL-based backend (CDN/S3)
      const url = new URL(id, "https://cdn.example.com/static/");
      console.log("[getMeta] resolved URL:", url.href);
      return { type: "text/plain" };
    },
    getContents(id) {
      console.log("[getContents] asset ID:", id);
      const url = new URL(id, "https://cdn.example.com/static/");
      console.log("[getContents] resolved URL:", url.href);
      return `Fetched from: ${url.href}`;
    },
  });
});

serve({ fetch: app.fetch, port: 3000 });

2. Send the double-encoded traversal request:

bash
curl -v 'http://localhost:3000/%252e%252e/%252e%252e/etc/passwd'

3. Observe server logs:

[getMeta] asset ID: /%2e%2e/%2e%2e/etc/passwd
[getMeta] resolved URL: https://cdn.example.com/etc/passwd
[getContents] asset ID: /%2e%2e/%2e%2e/etc/passwd
[getContents] resolved URL: https://cdn.example.com/etc/passwd

The %2e%2e sequences in the asset ID are resolved as .. by the URL constructor, causing the backend URL to traverse from /static/ to /etc/passwd.

Impact

  • Arbitrary file read from backend storage: An unauthenticated attacker can read files outside the intended static asset directory on any URL-based backend (CDN origins, S3 buckets, object storage, reverse-proxied file servers).
  • Sensitive data exposure: Depending on the backend, this could expose configuration files, credentials, source code, or other tenants' data in shared storage.
  • Affected deployments: Applications using serveStatic with callbacks that resolve asset IDs via URL construction (new URL(id, baseUrl) or equivalent). This is a common pattern for CDN proxying and cloud object storage backends. Filesystem-based backends using path.join() are not affected since %2e%2e is not resolved as a traversal sequence by filesystem APIs.

Recommended Fix

The resolveDotSegments() function must account for percent-encoded dot sequences. Additionally, the redundant decodeURI() in serveStatic should be removed since H3Event already handles decoding.

Fix 1 — Remove redundant decodeURI in src/utils/static.ts:86-88:

diff
  const originalId = resolveDotSegments(
-   decodeURI(withLeadingSlash(withoutTrailingSlash(event.url.pathname))),
+   withLeadingSlash(withoutTrailingSlash(event.url.pathname)),
  );

Fix 2 — Harden resolveDotSegments in src/utils/internal/path.ts:55-73 to handle percent-encoded dots:

diff
 export function resolveDotSegments(path: string): string {
-  if (!path.includes(".")) {
+  if (!path.includes(".") && !path.toLowerCase().includes("%2e")) {
     return path;
   }
   // Normalize backslashes to forward slashes to prevent traversal via `\`
-  const segments = path.replaceAll("\\", "/").split("/");
+  const segments = path.replaceAll("\\", "/")
+    .replaceAll(/%2e/gi, ".")
+    .split("/");
   const resolved: string[] = [];

Both fixes should be applied. Fix 1 removes the unnecessary double-decode. Fix 2 provides defense-in-depth by ensuring resolveDotSegments cannot be bypassed with percent-encoded dots regardless of the caller.

Czego dotyczy

Kliknij technologię, aby zobaczyć wszystkie dotyczące jej podatności.

PlatformaPaczkaPodatne wersjePoprawka w wersji
Paczka npm h3 < 1.15.9 1.15.9

Odnośniki

Ocena CVSS 5.9/10
Wektor CVSS
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N
Słabość (CWE)
CWE-22
Platforma
npm
Paczka
h3
Ujawniono
20.03.2026
Aktualizacja
11.10.2026 10:22

Źródła i licencje

OSV / GitHub Advisory Database - GHSA-72gr-qfp7-vwhw - CC BY 4.0

Historia podatności: h3

Wszystkie opisane podatności tej paczki (Paczka npm), od najnowszej. Widać, czy to jednorazowa wpadka, czy paczka, której trzeba pilnować.

Wszystkie (10) w archiwum
Wszystkich wpisów
10
Pierwsza → ostatnia
01.2026 03.2026
Bez poprawki
0
Wykorzystywana w atakach
0

Rozkład istotności

  • Wysoka 3
  • Średnia 6
  • Niska 1

Ujawnienia w latach

  1. 2026: 10
Istotność:
  1. h3: Double Decoding in `serveStatic` Bypasses `resolveDotSegments` Path Traversal Protection via `%252e%252e` ten wpis
    Średnia · 5.9 CVE-2026-86251 < 1.15.9 1.15.9
  2. Wysoka · 7.4 CVE-2026-33131 >= 2.0.0-0, < 2.0.1-rc.15 2.0.1-rc.15
  3. Wysoka · 7.5 CVE-2026-33128 >= 2.0.0, < 2.0.1-rc.15, < 1.15.6 2.0.1-rc.15, 1.15.6
  4. Wysoka · 8.9 CVE-2026-23527 < 1.15.5 1.15.5

Dowiedz się, które podatności dotyczą Twojej aplikacji

Po podłączeniu integracji DockRay i włączeniu raportowania oprogramowania DockRay porównuje zgłoszone wersje komponentów ze znanymi podatnościami i powiadamia o potwierdzonych dopasowaniach. Zakres zależy od obsługiwanej technologii i dostępnych biuletynów.

Podłącz swoją aplikację
Newsletter DockRay

Bezpieczeństwo aplikacji w Twojej skrzynce

Najważniejsze podatności, konkretne zalecenia i praktyczne wskazówki dla Twoich aplikacji. Wybierz technologie i śledź to, co ma znaczenie dla Twojego projektu.

  • Codzienne zestawienie nowych podatności, także krytycznych.
  • Powiadomienia o ważnych aktualizacjach.
  • Praktyczne porady o bezpieczeństwie i monitoringu aplikacji.

Pozostaw puste, aby otrzymywać informacje o wszystkich technologiach.

Zapis jest bezpłatny. Wypiszesz się jednym kliknięciem w wiadomości.