@timber-js/app 0.2.0-alpha.131 → 0.2.0-alpha.132

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1615,6 +1615,16 @@ function escapeXml(str) {
1615
1615
  * See design/07-routing.md §"Intercepting Routes"
1616
1616
  */
1617
1617
  /**
1618
+ * Check if a pathname starts with a prefix on a segment boundary.
1619
+ *
1620
+ * Prevents /feed from matching /feed-private — the prefix must be
1621
+ * followed by '/' or be an exact match.
1622
+ */
1623
+ function hasSegmentPrefix(pathname, prefix) {
1624
+ if (!pathname.startsWith(prefix)) return false;
1625
+ return pathname.length === prefix.length || pathname[prefix.length] === "/";
1626
+ }
1627
+ /**
1618
1628
  * Check if an intercepting route applies for this soft navigation.
1619
1629
  *
1620
1630
  * Matches the target pathname against interception rewrites, constrained
@@ -1624,7 +1634,7 @@ function escapeXml(str) {
1624
1634
  */
1625
1635
  function findInterceptionMatch(targetPathname, sourceUrl, rewrites) {
1626
1636
  for (const rewrite of rewrites) {
1627
- if (!sourceUrl.startsWith(rewrite.interceptingPrefix)) continue;
1637
+ if (!hasSegmentPrefix(sourceUrl, rewrite.interceptingPrefix)) continue;
1628
1638
  if (pathnameMatchesPattern(targetPathname, rewrite.interceptedPattern)) return { sourcePathname: rewrite.interceptingPrefix };
1629
1639
  }
1630
1640
  return null;
@@ -1792,6 +1802,25 @@ async function outcomeToResponse(config, outcome, ctx) {
1792
1802
  * See design/07-routing.md §"Request Lifecycle", design/02-rendering-pipeline.md §"Request Flow".
1793
1803
  */
1794
1804
  /**
1805
+ * Validate and canonicalize the X-Timber-URL header value.
1806
+ *
1807
+ * Returns the canonical pathname if valid, or null if rejected:
1808
+ * - Must start with '/' (relative pathname, no scheme/authority)
1809
+ * - Must not contain control characters
1810
+ * - Must pass canonicalization (no encoded separators, null bytes, etc.)
1811
+ */
1812
+ function validateInterceptionHeader(raw, stripTrailingSlash) {
1813
+ if (!raw.startsWith("/")) return null;
1814
+ if (raw.startsWith("//")) return null;
1815
+ for (let i = 0; i < raw.length; i++) {
1816
+ const code = raw.charCodeAt(i);
1817
+ if (code <= 31 || code === 127) return null;
1818
+ }
1819
+ const result = canonicalize(raw, stripTrailingSlash);
1820
+ if (!result.ok) return null;
1821
+ return result.pathname;
1822
+ }
1823
+ /**
1795
1824
  * Run the proxy.ts phase. Calls user proxy code and uses `handleRequest` as
1796
1825
  * the inner `next()` continuation. The proxy resolver was picked at pipeline
1797
1826
  * construction time so the hot path sees no per-request branching on the
@@ -1976,7 +2005,8 @@ async function handleRequest(config, req, method, path) {
1976
2005
  if (config.onPipelineError && error instanceof Error) config.onPipelineError(error, "auto-sitemap");
1977
2006
  return new Response(null, { status: 500 });
1978
2007
  }
1979
- if ((req.headers.get("Accept") ?? "").includes("text/x-component")) {
2008
+ const isRscRequest = (req.headers.get("Accept") ?? "").includes("text/x-component");
2009
+ if (isRscRequest) {
1980
2010
  if (!checkVersionSkew(req).ok) {
1981
2011
  const reloadHeaders = new Headers();
1982
2012
  applyReloadHeaders(reloadHeaders);
@@ -1988,14 +2018,17 @@ async function handleRequest(config, req, method, path) {
1988
2018
  }
1989
2019
  let match = config.matchRoute(canonicalPathname);
1990
2020
  let interception;
1991
- const sourceUrl = req.headers.get("X-Timber-URL");
1992
- if (sourceUrl && config.interceptionRewrites?.length) {
1993
- const intercepted = findInterceptionMatch(canonicalPathname, sourceUrl, config.interceptionRewrites);
1994
- if (intercepted) {
1995
- const sourceMatch = config.matchRoute(intercepted.sourcePathname);
1996
- if (sourceMatch) {
1997
- match = sourceMatch;
1998
- interception = { targetPathname: canonicalPathname };
2021
+ if (isRscRequest && config.interceptionRewrites?.length) {
2022
+ const rawSourceUrl = req.headers.get("X-Timber-URL");
2023
+ const validatedSourceUrl = rawSourceUrl ? validateInterceptionHeader(rawSourceUrl, stripTrailingSlash) : null;
2024
+ if (validatedSourceUrl) {
2025
+ const intercepted = findInterceptionMatch(canonicalPathname, validatedSourceUrl, config.interceptionRewrites);
2026
+ if (intercepted) {
2027
+ const sourceMatch = config.matchRoute(intercepted.sourcePathname);
2028
+ if (sourceMatch) {
2029
+ match = sourceMatch;
2030
+ interception = { targetPathname: canonicalPathname };
2031
+ }
1999
2032
  }
2000
2033
  }
2001
2034
  }
@@ -2084,13 +2117,26 @@ function createPipeline(config) {
2084
2117
  }, async () => {
2085
2118
  const otelIds = await getOtelTraceId();
2086
2119
  if (otelIds) replaceTraceId(otelIds.traceId, otelIds.spanId);
2120
+ const stripTrailingSlash = config.stripTrailingSlash ?? true;
2121
+ const canonResult = canonicalize(url.pathname, stripTrailingSlash);
2122
+ if (!canonResult.ok) {
2123
+ if (isDebug()) console.warn(`[timber] URL canonicalization rejected ${method} ${url.pathname} — responding with ${canonResult.status}\n This usually means the URL contains encoded separators (%2f, %5c),\n null bytes (%00), path traversal (..), or malformed percent-encoding.`);
2124
+ return new Response(null, { status: canonResult.status });
2125
+ }
2126
+ const canonicalPath = canonResult.pathname;
2127
+ let canonicalReq = req;
2128
+ if (url.pathname !== canonicalPath) {
2129
+ const canonicalUrl = new URL(req.url);
2130
+ canonicalUrl.pathname = canonicalPath;
2131
+ canonicalReq = new Request(canonicalUrl.toString(), req);
2132
+ }
2087
2133
  let result;
2088
- if (proxyResolver) result = await outcomeToResponse(config, await runProxyPhase(config, proxyResolver, req, method, path), {
2089
- req,
2134
+ if (proxyResolver) result = await outcomeToResponse(config, await runProxyPhase(config, proxyResolver, canonicalReq, method, canonicalPath), {
2135
+ req: canonicalReq,
2090
2136
  method,
2091
- path
2137
+ path: canonicalPath
2092
2138
  });
2093
- else result = await handleRequest(config, req, method, path);
2139
+ else result = await handleRequest(config, canonicalReq, method, canonicalPath);
2094
2140
  await setSpanAttribute("http.response.status_code", result.status);
2095
2141
  if (serverTiming === "detailed") {
2096
2142
  const timingHeader = getServerTimingHeader();