Skip to content

Collection permalinks containing .html return 404 on Ghost 6 (worked on Ghost 5) #29991

Description

@truekasun

Collection permalinks containing .html return 404 on Ghost 6 (worked on Ghost 5)

Summary

A routes.yaml collection permalink that includes a .html suffix (common on sites migrated from WordPress or other platforms that preserved legacy URLs) returns a plain-text File not found 404 on Ghost 6. The same routes file works correctly on Ghost 5.

Ghost still generates these URLs everywhere (post cards, sitemap, canonical tags), so the site links to URLs that Ghost itself then refuses to serve.

Environment

  • Ghost version: 6.57.1 (the relevant middleware is unchanged on current main)
  • Worked on: Ghost 5.x (production site currently live on 5.x with this exact routes file)
  • Node: v22.23.1
  • Database: sqlite3 (dev) / MySQL (production) — not database-related
  • Install: ghost-cli local install

Steps to reproduce

  1. Upload this routes.yaml:

    collections:
      /:
        permalink: /{primary_tag}/{slug}.html/
        template: index
    
    taxonomies:
      tag: /category/{slug}/
      author: /author/{slug}/
  2. Publish a post with a primary tag, e.g. slug my-post with primary tag finance.

  3. Visit the homepage: the post card correctly links to /finance/my-post.html/.

  4. Visit /finance/my-post.html/ (or /finance/my-post.html without the trailing slash).

Expected: the post renders (Ghost 5 behavior).

Actual: HTTP 404 with the plain-text body File not found.

Root cause

core/frontend/web/middleware/static-theme.js sends every request path that has a file extension to express.static with fallthrough set to false unless the path is in the hard-coded isFallthroughFile allowlist (robots.txt, sitemaps, llms.txt):

function forwardToExpressStatic(req, res, next, options = {}) {
    ...
    const fallthrough = isFallthroughFile(req.path);

    express.static(themeEngine.getActive().path, Object.assign({
        maxAge: config.get('caching:theme:maxAge') * 1000,
        fallthrough
    }, options))(req, res, next);
}

path.extname('/finance/my-post.html/') returns .html (trailing slash is stripped by path.basename), so the request is classified as a static file request. .html is not in DENIED_FILE_TYPES and the file does not exist in the theme directory, so with fallthrough: false the request dies here with a 404 and never reaches the dynamic collection router.

The 404 is then rendered as plain text by core/frontend/web/middleware/error-handler.js, which treats any extension-bearing 404 as a static-file error (hasExtension check in themeErrorRenderer).

On Ghost 5 the static miss fell through to the dynamic router, so the collection route (mounted as /:primary_tag/:slug.html/) matched and served the post.

Note the internal inconsistency: route-settings/validate.js accepts the permalink, the URL service happily generates /{primary_tag}/{slug}.html/ URLs for every post (homepage, sitemap, canonical), and the collection router mounts a matching Express route — but the static-theme middleware blocks every request to those URLs before routing runs.

Workaround

Adding .html to DENIED_FILE_TYPES in static-theme.js restores Ghost 5 behavior (denied extensions skip static serving and fall through to the dynamic router), at the cost of not being able to serve raw .html files from the theme root. This obviously does not survive ghost update.

Suggested fix directions

  • Only use fallthrough: false for paths that plausibly target theme static files (e.g. under /assets/, or extensions that exist in the theme), and fall through otherwise; or
  • Check registered collection permalinks for extension-bearing patterns and skip the static short-circuit for matching requests; or
  • At minimum, reject .html (and other extension) permalinks in route-settings/validate.js so the misconfiguration fails loudly at upload time instead of silently 404ing every post.

Happy to provide more detail or test a fix.

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs:triage[triage] this needs to be triaged by the Ghost team

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions