Skip to content

fix(cli): #1767 serve output directory files whose extension no standard resource plugin claims - #1771

Open
jstockdi wants to merge 2 commits into
ProjectEvergreen:masterfrom
Battle-Creek-LLC:bug/issue-1767-serve-unclaimed-extensions
Open

fix(cli): #1767 serve output directory files whose extension no standard resource plugin claims#1771
jstockdi wants to merge 2 commits into
ProjectEvergreen:masterfrom
Battle-Creek-LLC:bug/issue-1767-serve-unclaimed-extensions

Conversation

@jstockdi

@jstockdi jstockdi commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Related Issue

Resolves #1767

Documentation

No user facing documentation changes. This restores the behaviour the docs already imply for greenwood serve — that the production server serves what is in the output directory — so nothing in the docs needs to change. Notably plugin-copy-robots and plugin-copy-sitemap are default plugins that put robots.txt and sitemap.xml into the output directory, and until now serve would 404 both of them.

Summary of Changes

getStaticServer in packages/cli/src/lifecycles/serve.js confirms the requested file exists in the output directory, then narrows the Greenwood default plugins to the ones flagged isStandardStaticResource and asks each one's shouldServe. Those plugins cover js, json, css, images, fonts, audio, video, wasm and .map. Nothing covers .xml or .txt, and there was no fallback, so response stayed the initResponse built from ctx.response.status — still Koa's default 404 at that point in the chain. if (response.ok) was therefore false, nothing was assigned to ctx, and Koa emitted its own default 404 (text/plain; charset=utf-8, body Not Found).

The result is that a file which is demonstrably present in public/ is unreachable through the production server. getHybridServer composes getStaticServer, so both server paths behave identically.

The fix

After the plugin loop, if and only if no plugin claimed the request (response === initResponse), look the extension up in a small fixed map and serve the file with that Content-Type:

const STATIC_FALLBACK_CONTENT_TYPES = {
  csv: "text/csv",
  txt: "text/plain",
  webmanifest: "application/manifest+json",
  xml: "application/xml",
};

An extension that is not in the map is left exactly as it is today and still falls through to a 404.

Why an allowlist rather than "serve any file that exists"

The obvious fix is to read and return whatever is on disk once checkResourceExists passes, and I deliberately did not do that. It would broaden what the production server hands out to every file that has ever landed in the output directory, including anything a copy plugin, an adapter, or a user's own build step drops there, and it would do so silently with no Content-Type the server can reason about. That is a much bigger behavioural change than the issue calls for, and it is the sort of change that is hard to walk back once people depend on it.

The allowlist keeps the change proportional. It is a closed set of text/data extensions that a static host would serve out of a web root anyway, chosen around what Greenwood itself emits (sitemap.xml and robots.txt via the default copy plugins) plus the two obvious neighbours (.webmanifest, .csv). Adding a further extension later is a one line, reviewable diff. Deliberately not included: .html (already handled by the page/SPA middleware further down the chain) and anything executable or binary.

This is contained entirely within getStaticServer and introduces no new resource plugin and no change to the plugin contract or to the isStandardStaticResource flag.

This does not weaken the path traversal hardening from #1749

The fallback is nested inside the existing guard and runs only after both of its checks have already passed:

const isWithinOutputDir = url.href.startsWith(outputDir.href);

if (isWithinOutputDir && (await checkResourceExists(url))) {
  // ... plugin loop, then the new fallback
}

The isWithinOutputDir boundary check added in #1749 is untouched, and the fallback cannot be reached for any URL that check rejects — a ../ or percent-encoded traversal never enters this branch at all, so it never reaches the extension lookup. The fallback also narrows further rather than widening: it only fires for a path that is inside the output directory, exists on disk, and whose extension is in the fixed map. The regression tests in serve.default.path-traversal still pass unchanged.

Testing

Folded into the existing serve.default case rather than adding a new test directory, alongside the other per-content-type assertions there. Fixtures added: src/sitemap.xml, src/robots.txt, src/assets/data.xml.

New assertions cover status, Content-Type and body for:

  • /sitemap.xml — 200, application/xml
  • /robots.txt — 200, text/plain
  • /assets/data.xml — 200, application/xml (so this is not special casing two filenames)

Plus a negative assertion that a genuinely missing /assets/definitely-missing.xml still returns 404 with body Not Found and Content-Type: text/plain, so the fix cannot be mistaken for "serve everything on disk". That negative already passed before the fix and still passes after it.

On unpatched master the case reports 46 passing / 9 failing. With the fix it is 55 passing / 0 failing.

serve.default.path-traversal, serve.config.base-path, serve.default.api, serve.default.error, serve.spa and serve.config.static-router were also run and are unaffected.

Note on #1747

Not addressed here and nothing related to it is touched, but worth flagging for whoever reviews it: #1747 (dynamic sitemap) depends on serve returning the built public/sitemap.xml snapshot, and this gap is why that serve path is currently non-functional for local production smoke testing. #1747's own "Known caveats" already discloses it. This change removes that blocker without any coupling to #1747 — the same applies to an RSS feed or any other .xml artifact a future feature writes into the output directory.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant