Skip to content

Added file type validation and content-type control for file uploads#26869

Merged
vershwal merged 2 commits intomainfrom
fileValidation
Mar 19, 2026
Merged

Added file type validation and content-type control for file uploads#26869
vershwal merged 2 commits intomainfrom
fileValidation

Conversation

@vershwal
Copy link
Member

@vershwal vershwal commented Mar 18, 2026

ref https://linear.app/ghost/issue/HKG-1643/

The files upload endpoint accepted any file type without validation, unlike images and media which check extension and MIME type. With the move to a shared CDN domain, we don't want to upload files with executable content types.

This PR adds:

  • An allowlist of supported file extensions (based on Ghost Pro data)
  • Extension-only validation middleware on the upload route
  • Content-type resolution that serves browser-renderable types (images, PDF, JSON, audio, video, fonts) with their natural type, overrides formats (HTML, JS, CSS, XML) to text/plain, and defaults everything else to application/octet-stream (forced download)
  • Users can zip unsupported file types as an escape hatch

These are the lists and rules we have for extensions and content-types for files:-

1. Allowed Extensions (48 total)
These are the top 44 extensions from our Ghost Pro data. These are all the extensions that have count > 40 (and 4 more that were already added in Ghost so I didn't remove those from the allow list).
Total: 48 extensions, ~177,444 files

If someone uploads a file with an extension that's NOT in this list, the upload is rejected with a 415 error. They can always zip the file and upload as .zip.

Once the file is uploaded, we decide on the content-type to store in the GCS. This is done in 3 phases:-

  1. Neutralized Extensions (served as text/plain)
    These extensions can be executed by browsers, which is a security risk on a shared CDN domain. We override their content type to text/plain so browsers display them as plain text instead of executing them.

    .html → text/plain
    .htm  → text/plain
    .js   → text/plain
    .css  → text/plain
    .xml  → text/plain
  2. Browser-Renderable (served with original content type)
    These are safe file types where we keep the real content type so the browser can display/play them inline (e.g. show a PDF, play a video, render an image)

    Images:  .jpg .jpeg .png .gif .webp → image/*
    PDF:     .pdf → application/pdf
    JSON:    .json → application/json
    Audio:   .mp3 .wav .m4a → audio/*
    Video:   .mp4 .mov → video/*
    Fonts:   .otf .woff .woff2 → font/*
    Text:    .txt → text/plain
  3. Everything else - Default (e.g. .docx, .xlsx, .psd, .zip, .epub, .svg, etc.) is served as application/octet-stream, which forces the browser to download the file instead of trying to open it.

@vershwal vershwal requested a review from Copilot March 18, 2026 09:06
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 18, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • ✅ Review completed - (🔄 Check again to review again)

Walkthrough

This pull request refactors file type handling by introducing a centralized content-type resolution function (getStorageContentType) that replaces direct mime-types library usage. A new validation middleware checks file extensions against an allowlist during uploads. The allowed file extensions for general file uploads are expanded to include document, archive, code, and media formats. The content-type resolution uses a three-tier approach: extension overrides (returning text/plain for executable types like .html and .js), browser-renderable type preservation, and fallback to application/octet-stream. Comprehensive tests validate content-type determination and file upload validation behavior.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding file type validation and content-type control for file uploads, which is the primary focus of the changeset.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, explaining the motivation, implementation details, and the three-phase content-type resolution strategy.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fileValidation
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the Admin “files/upload” endpoint by introducing an extension allowlist and by controlling the stored/served Content-Type to reduce the risk of executable content being rendered after moving uploads to a shared CDN domain.

Changes:

  • Added extension allowlist validation middleware for /files/upload and updated config to include the allowed extensions.
  • Introduced getStorageContentType() to derive a safe storage content-type from filename extension (override to text/plain, allow safe renderable types, otherwise application/octet-stream).
  • Added/updated unit and e2e tests to cover extension rejection and content-type behavior.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
ghost/core/test/unit/server/services/files/file-type-utils.test.js New unit tests for storage content-type derivation logic.
ghost/core/test/unit/server/data/importer/index.test.js Updates importer extension/content-type expectations to align with new allowlist/config behavior.
ghost/core/test/e2e-api/admin/files.test.js Adds e2e assertions for 415 on unsupported extensions and for safe stored content-types.
ghost/core/core/shared/config/overrides.json Expands uploads files.extensions allowlist used by validation middleware.
ghost/core/core/server/web/api/middleware/upload.js Adds fileValidation middleware and new error messages for file uploads.
ghost/core/core/server/web/api/endpoints/admin/routes.js Wires fileValidation({type: 'files'}) into the files upload route.
ghost/core/core/server/services/files/file-type-utils.js New helper to determine safe storage/served content type from extension.
ghost/core/core/server/api/endpoints/files.js Switches file upload storage type derivation to getStorageContentType().

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ghost/core/core/server/services/files/file-type-utils.js`:
- Around line 12-34: The BROWSER_RENDERABLE_TYPES set in file-type-utils.js
omits 'video/quicktime', so mime.lookup('.mov') yields 'video/quicktime' and is
treated as non-renderable; update the BROWSER_RENDERABLE_TYPES constant to
include 'video/quicktime' (or broaden the renderable check to accept video/*) so
that mime.lookup results for .mov are preserved as renderable instead of being
forced to 'application/octet-stream' in the downstream logic that uses
BROWSER_RENDERABLE_TYPES.

In `@ghost/core/test/e2e-api/admin/files.test.js`:
- Around line 99-165: The tests use fixed temp filenames (tempFile with
'test.exe', 'spreadsheet.xlsx', 'page.html') in os.tmpdir(), causing races in
parallel runs; modify each test to create a unique temporary directory or
filename (e.g., via fs.mkdtempSync(path.join(os.tmpdir(), 'ghost-test-')) or
generate a random suffix) and write the file into that unique location, then use
that path for .attach(...) and fs.removeSync, ensuring the save spies and
assertions still reference the created file; update the three tests (the
"Rejects an unsupported file extension...", "Stores non-browser-renderable
files...", and "Stores HTML files..." blocks) to use the unique temp path for
tempFile.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e70afb27-8a8e-44db-9e4d-e87c0a9f8a19

📥 Commits

Reviewing files that changed from the base of the PR and between 29dc800 and 30bfaf8.

📒 Files selected for processing (8)
  • ghost/core/core/server/api/endpoints/files.js
  • ghost/core/core/server/services/files/file-type-utils.js
  • ghost/core/core/server/web/api/endpoints/admin/routes.js
  • ghost/core/core/server/web/api/middleware/upload.js
  • ghost/core/core/shared/config/overrides.json
  • ghost/core/test/e2e-api/admin/files.test.js
  • ghost/core/test/unit/server/data/importer/index.test.js
  • ghost/core/test/unit/server/services/files/file-type-utils.test.js

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
ghost/core/core/server/services/files/file-type-utils.js (1)

12-35: Add a drift guardrail test between allowed extensions and renderable MIME handling.

This list is easy to drift from upload extension config (as seen with .mov earlier). A small unit test that snapshots expected resolution for all allowed extensions would catch regressions early.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/services/files/file-type-utils.js` around lines 12 -
35, Add a unit test that prevents drift between allowed upload extensions and
the MIME types considered renderable by the browser: load the canonical
allowed-extensions list used by the uploader and, for each extension, resolve
its expected MIME handling (using the same resolution logic your code uses) and
assert it matches a stored snapshot or that the resolved MIME is present in the
BROWSER_RENDERABLE_TYPES Set from file-type-utils.js; name the test clearly
(e.g., "uploads: allowed extensions -> renderable MIME snapshot") so it fails
when someone adds/removes extensions (like .mov) without updating
BROWSER_RENDERABLE_TYPES.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@ghost/core/core/server/services/files/file-type-utils.js`:
- Around line 12-35: Add a unit test that prevents drift between allowed upload
extensions and the MIME types considered renderable by the browser: load the
canonical allowed-extensions list used by the uploader and, for each extension,
resolve its expected MIME handling (using the same resolution logic your code
uses) and assert it matches a stored snapshot or that the resolved MIME is
present in the BROWSER_RENDERABLE_TYPES Set from file-type-utils.js; name the
test clearly (e.g., "uploads: allowed extensions -> renderable MIME snapshot")
so it fails when someone adds/removes extensions (like .mov) without updating
BROWSER_RENDERABLE_TYPES.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 33f2e89c-a0f3-4f6b-9f1f-9a0e52bbfee4

📥 Commits

Reviewing files that changed from the base of the PR and between 30bfaf8 and 96acec2.

📒 Files selected for processing (2)
  • ghost/core/core/server/services/files/file-type-utils.js
  • ghost/core/test/unit/server/services/files/file-type-utils.test.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • ghost/core/test/unit/server/services/files/file-type-utils.test.js

@vershwal vershwal force-pushed the fileValidation branch 3 times, most recently from 928a978 to 3c91860 Compare March 18, 2026 11:15
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
ghost/core/core/server/web/api/middleware/upload.js (1)

363-397: Implementation looks solid; minor inconsistency with template parameter.

The extension-only validation approach is well-documented and appropriate given MIME type unreliability. One small nit: line 391 passes {extensions: extensions} to tpl(), but the files.invalidFile message at line 58 doesn't use this placeholder.

🧹 Suggested cleanup to remove unused parameter
         if (!extensions.includes(req.file.ext)) {
             return next(new errors.UnsupportedMediaTypeError({
-                message: tpl(messages[type].invalidFile, {extensions: extensions})
+                message: tpl(messages[type].invalidFile)
             }));
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/server/web/api/middleware/upload.js` around lines 363 - 397,
The tpl call in fileUploadValidation passes an unused parameter object
({extensions: extensions}) to tpl for messages[type].invalidFile; remove the
extra parameter so tpl is invoked with only the formatted message (i.e., change
the UnsupportedMediaTypeError message construction in
fileValidation/fileUploadValidation to call tpl(messages[type].invalidFile)
without the unused {extensions} arg) and ensure no other calls rely on that
placeholder.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@ghost/core/core/server/web/api/middleware/upload.js`:
- Around line 363-397: The tpl call in fileUploadValidation passes an unused
parameter object ({extensions: extensions}) to tpl for
messages[type].invalidFile; remove the extra parameter so tpl is invoked with
only the formatted message (i.e., change the UnsupportedMediaTypeError message
construction in fileValidation/fileUploadValidation to call
tpl(messages[type].invalidFile) without the unused {extensions} arg) and ensure
no other calls rely on that placeholder.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 66169af8-4b0e-410c-b85b-3dccb54b1bd3

📥 Commits

Reviewing files that changed from the base of the PR and between 706cdea and 3c91860.

📒 Files selected for processing (8)
  • ghost/core/core/server/api/endpoints/files.js
  • ghost/core/core/server/services/files/file-type-utils.js
  • ghost/core/core/server/web/api/endpoints/admin/routes.js
  • ghost/core/core/server/web/api/middleware/upload.js
  • ghost/core/core/shared/config/overrides.json
  • ghost/core/test/e2e-api/admin/files.test.js
  • ghost/core/test/unit/server/data/importer/index.test.js
  • ghost/core/test/unit/server/services/files/file-type-utils.test.js
🚧 Files skipped from review as they are similar to previous changes (5)
  • ghost/core/test/e2e-api/admin/files.test.js
  • ghost/core/core/server/api/endpoints/files.js
  • ghost/core/core/server/web/api/endpoints/admin/routes.js
  • ghost/core/test/unit/server/services/files/file-type-utils.test.js
  • ghost/core/core/server/services/files/file-type-utils.js

".pdf",
".json",
".jsonld",
".odp",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed these extensions because their count was very low in our data.

@vershwal vershwal requested a review from allouis March 18, 2026 11:40
Copy link
Collaborator

@allouis allouis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Few comments!

@codecov
Copy link

codecov bot commented Mar 18, 2026

Codecov Report

❌ Patch coverage is 93.06931% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.11%. Comparing base (822bf33) to head (b2b2b0d).
⚠️ Report is 17 commits behind head on main.

Files with missing lines Patch % Lines
...host/core/core/server/web/api/middleware/upload.js 83.33% 7 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #26869      +/-   ##
==========================================
+ Coverage   73.08%   73.11%   +0.02%     
==========================================
  Files        1535     1536       +1     
  Lines      121270   121368      +98     
  Branches    14645    14660      +15     
==========================================
+ Hits        88633    88737     +104     
- Misses      31609    31625      +16     
+ Partials     1028     1006      -22     
Flag Coverage Δ
admin-tests 54.32% <ø> (+0.01%) ⬆️
e2e-tests 73.11% <93.06%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The files upload endpoint accepted any file type without validation, unlike images and media which check extension and MIME type. With the move to a shared CDN domain, we don't want to upload files with executable content types.

This adds:
- An allowlist of supported file extensions (based on Ghost Pro data)
- Extension-only validation middleware on the upload route
- Content-type resolution that serves browser-renderable types (images, PDF, JSON, audio, video, fonts) with their natural type, overrides formats (HTML, JS, CSS, XML) to text/plain, and defaults everything else to application/octet-stream (forced download)
- Users can zip unsupported file types as an escape hatch

Made-with: Cursor
@vershwal vershwal requested a review from allouis March 18, 2026 13:19
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ghost/core/core/shared/config/overrides.json`:
- Line 97: The empty uploads.files.contentTypes array causes the upload
validator to always fail (it requires both MIME type and extension), so either
populate uploads.files.contentTypes with the expected MIME types for your
allowed extensions (e.g., image/png, image/jpeg, video/mp4, etc.) or update the
upload validation logic to treat an empty contentTypes as a wildcard/skip for
MIME checking (i.e., if uploads.files.contentTypes.length === 0, validate by
extension only or allow any MIME), ensuring the /files/upload validation path is
updated accordingly (adjust the validator used by the upload handler).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c1098442-b543-4dfb-b9e4-c86884c22b7d

📥 Commits

Reviewing files that changed from the base of the PR and between 3c91860 and b2b2b0d.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (9)
  • ghost/core/core/server/api/endpoints/files.js
  • ghost/core/core/server/services/files/file-type-utils.ts
  • ghost/core/core/server/web/api/endpoints/admin/routes.js
  • ghost/core/core/server/web/api/middleware/upload.js
  • ghost/core/core/shared/config/overrides.json
  • ghost/core/package.json
  • ghost/core/test/e2e-api/admin/files.test.js
  • ghost/core/test/unit/server/data/importer/index.test.js
  • ghost/core/test/unit/server/services/files/file-type-utils.test.js
🚧 Files skipped from review as they are similar to previous changes (3)
  • ghost/core/core/server/web/api/endpoints/admin/routes.js
  • ghost/core/core/server/services/files/file-type-utils.ts
  • ghost/core/test/unit/server/services/files/file-type-utils.test.js

"application/xml",
"application/atom+xml"
]
"contentTypes": []
Copy link
Contributor

@coderabbitai coderabbitai bot Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "1) Find any middleware usage still validating files via MIME-based validation():"
rg -nP "validation\(\{\s*type:\s*['\"]files['\"]\s*\}\)|upload\.validation\(\{\s*type:\s*['\"]files['\"]\s*\}\)" --type=js --type=ts

echo
echo "2) Find direct consumers of uploads.files.contentTypes:"
rg -nP "config\.get\('uploads'\)\.files\.contentTypes|config\.get\(\"uploads\"\)\.files\.contentTypes|uploads'\)\.files\.contentTypes" --type=js --type=ts

echo
echo "3) Inspect importer wiring that still passes files.contentTypes through:"
rg -nP "contentTypes:\s*config\.get\('uploads'\)\.files\.contentTypes" --type=js --type=ts -C2

Repository: TryGhost/Ghost

Length of output: 138


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Step 1: Simple pattern search for contentTypes keyword ===" 
rg -nw "contentTypes" --type=js --type=ts | head -30

echo
echo "=== Step 2: Look at the actual config file changes ===" 
git show HEAD:ghost/core/core/shared/config/overrides.json | sed -n '85,110p' || echo "Using git diff to see the change..."
git diff HEAD~1 ghost/core/core/shared/config/overrides.json | head -40 || echo "Checking file directly..."
cat ghost/core/core/shared/config/overrides.json | sed -n '85,110p'

echo
echo "=== Step 3: Find upload/files handlers and validators ===" 
fd -e "js" -e "ts" -path "*upload*" | grep -v node_modules | head -20

echo
echo "=== Step 4: Check for any files-related validation middleware ===" 
rg -l "files.*upload|upload.*files" --type=js --type=ts | head -10

Repository: TryGhost/Ghost

Length of output: 6905


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Reading upload.js around the contentTypes usage (lines 260-340) ==="
sed -n '260,340p' ghost/core/core/server/web/api/middleware/upload.js

echo
echo "=== Find the checkFileIsValid function definition ==="
rg -n "function checkFileIsValid|const checkFileIsValid" --type=js --type=ts -A 15

echo
echo "=== Check the exports/usage patterns in upload.js ==="
head -50 ghost/core/core/server/web/api/middleware/upload.js

echo
echo "=== Check which routes use this upload middleware for files ==="
rg -n "upload\." --type=js --type=ts -B 2 -A 2 | grep -E "(files|upload\..*function)" | head -20

Repository: TryGhost/Ghost

Length of output: 7343


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Full checkFileIsValid function context (lines 150-175) ==="
sed -n '150,175p' ghost/core/core/server/web/api/middleware/upload.js

echo
echo "=== Verify files extension array is populated ==="
rg -n '"files"' ghost/core/core/shared/config/overrides.json -A 80 | grep -E "(extensions|contentTypes)" | head -5

echo
echo "=== Check for any recent changes or special handling ==="
rg -n "contentTypes.*empty|types\.length|types\.length === 0" --type=js --type=ts

echo
echo "=== Look at the fileValidation function structure ==="
sed -n '245,280p' ghost/core/core/server/web/api/middleware/upload.js

Repository: TryGhost/Ghost

Length of output: 1222


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Search for extension-only validation or alternative logic ==="
rg -n "extension.*only|extensionsOnly" --type=js --type=ts | head -10

echo
echo "=== Check if checkFileIsValid logic was changed recently ==="
git log --oneline ghost/core/core/server/web/api/middleware/upload.js | head -5

echo
echo "=== Look for any TODO or FIXME comments in upload.js ==="
rg -n "TODO|FIXME|XXX" ghost/core/core/server/web/api/middleware/upload.js

echo
echo "=== Check if there's alternative validation using only extensions ==="
rg -n "extensions\.includes" --type=js --type=ts -B 2 -A 2 | head -30

echo
echo "=== View the full fileValidation function ==="
sed -n '239,290p' ghost/core/core/server/web/api/middleware/upload.js

Repository: TryGhost/Ghost

Length of output: 482


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Show the recent commit that changed the file type utils ==="
git show b2b2b0d1 --stat | head -30

echo
echo "=== Check if checkFileIsValid was modified in that commit ==="
git show b2b2b0d1 -- ghost/core/core/server/web/api/middleware/upload.js | grep -A 10 -B 10 "checkFileIsValid" | head -40

echo
echo "=== Full fileValidation middleware for 'files' type ==="
sed -n '239,295p' ghost/core/core/server/web/api/middleware/upload.js

echo
echo "=== Check if there's a separate or new validation path ==="
rg -n "fileValidation|uploadValidation" ghost/core/core/server/web/api/middleware/upload.js | head -10

echo
echo "=== Verify the actual behavior by seeing the complete exports ==="
tail -100 ghost/core/core/server/web/api/middleware/upload.js | grep -E "module.exports|exports\.|fileValidation"

Repository: TryGhost/Ghost

Length of output: 1835


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Check the complete fileValidation export ==="
rg -n "^module.exports|^exports\." ghost/core/core/server/web/api/middleware/upload.js -A 5

echo
echo "=== Check if contentTypes validation logic was modified ==="
sed -n '158,166p' ghost/core/core/server/web/api/middleware/upload.js

echo
echo "=== See all places where checkFileIsValid is called ==="
rg -n "checkFileIsValid" ghost/core/core/server/web/api/middleware/upload.js

echo
echo "=== Check the actual git diff for this specific file ==="
git diff HEAD~2 HEAD -- ghost/core/core/server/web/api/middleware/upload.js | head -100

echo
echo "=== Find what changed in overrides.json for files.contentTypes ==="
git diff HEAD~2 HEAD -- ghost/core/core/shared/config/overrides.json | grep -A 5 -B 5 "contentTypes"

Repository: TryGhost/Ghost

Length of output: 1120


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Check the files.extensions array to confirm it's populated ==="
sed -n '46,98p' ghost/core/core/shared/config/overrides.json | head -40

echo
echo "=== Look for any conditional logic that handles empty contentTypes ==="
rg -n "types\.length|!types|types\s*\|\|" --type=js --type=ts | grep -v test | grep -v node_modules | head -20

echo
echo "=== Check if OR logic is used instead of AND somewhere ==="
rg -n "includes.*\|\|.*includes" --type=js --type=ts

echo
echo "=== Search for the PR or issue that introduced extension-only validation ==="
rg -n "extension" ghost/core/core/server/web/api/middleware/upload.js | head -20

echo
echo "=== Check the surrounding code in fileValidation to see context ==="
sed -n '235,300p' ghost/core/core/server/web/api/middleware/upload.js

Repository: TryGhost/Ghost

Length of output: 1184


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Final confirmation: View checkFileIsValid and how it's used ==="
sed -n '158,166p' ghost/core/core/server/web/api/middleware/upload.js

echo
echo "=== Confirm files.contentTypes is indeed empty ==="
rg -A 2 '"files"' ghost/core/core/shared/config/overrides.json | grep -A 2 "contentTypes"

echo
echo "=== Double-check: is there any other validation path or fallback? ==="
rg -n "validate.*file|checkFile" --type=js --type=ts ghost/core/core/server/web/api/middleware/upload.js

echo
echo "=== Test: simulate the validation logic with empty contentTypes ==="
cat > /tmp/test_validation.js << 'TESTEOF'
const checkFileIsValid = (fileData, types, extensions) => {
    const type = fileData.mimetype;
    console.log(`Checking: mimetype=${type}, ext=${fileData.ext}`);
    console.log(`  types.includes(${type})? ${types.includes(type)}`);
    console.log(`  extensions.includes(${fileData.ext})? ${extensions.includes(fileData.ext)}`);
    if (types.includes(type) && extensions.includes(fileData.ext)) {
        return true;
    }
    return false;
};

// Test with empty contentTypes (current config)
const result1 = checkFileIsValid(
    {mimetype: 'application/pdf', ext: '.pdf'},
    [], // empty contentTypes from config
    ['.pdf', '.json'] // files.extensions
);
console.log(`\nResult with empty contentTypes: ${result1}`);

// Test with populated contentTypes
const result2 = checkFileIsValid(
    {mimetype: 'application/pdf', ext: '.pdf'},
    ['application/pdf', 'application/json'],
    ['.pdf', '.json']
);
console.log(`Result with populated contentTypes: ${result2}`);
TESTEOF
node /tmp/test_validation.js

Repository: TryGhost/Ghost

Length of output: 392


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Check git log for recent changes to upload.js ==="
git log --oneline -- ghost/core/core/server/web/api/middleware/upload.js | head -10

echo
echo "=== Get the actual commit that modified overrides.json ==="
git log --oneline -- ghost/core/core/shared/config/overrides.json | head -10

echo
echo "=== Look at the commit message/details ==="
git log -1 --format="%H%n%s%n%b" -- ghost/core/core/shared/config/overrides.json

echo
echo "=== Check if there's a comment or PR that explains extension-only approach ==="
rg -n "extension.*only|MIME.*deprecated|contentTypes.*unused" --type=js --type=ts --type=md | head -10

echo
echo "=== Look at the actual upload endpoint handler to see if there's custom validation ==="
rg -n "api.files.upload" --type=js --type=ts -B 5 -A 10

Repository: TryGhost/Ghost

Length of output: 2425


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Show the actual commit diff for upload.js to see what changed ==="
git show b2b2b0d1 -- ghost/core/core/server/web/api/middleware/upload.js | grep -A 15 -B 5 "checkFileIsValid" | head -50

echo
echo "=== Show the actual commit diff for overrides.json ==="
git show b2b2b0d1 -- ghost/core/core/shared/config/overrides.json | grep -A 20 -B 5 '"files"' | head -80

echo
echo "=== Test the actual behavior: can ANY file pass with empty contentTypes? ==="
node << 'EOF'
const checkFileIsValid = (fileData, types, extensions) => {
    const type = fileData.mimetype;
    if (types.includes(type) && extensions.includes(fileData.ext)) {
        return true;
    }
    return false;
};

// Simulate /files/upload validation
const testFiles = [
    {mimetype: 'application/pdf', ext: '.pdf', name: 'document.pdf'},
    {mimetype: 'text/plain', ext: '.txt', name: 'file.txt'},
    {mimetype: 'application/json', ext: '.json', name: 'data.json'}
];

const emptyContentTypes = []; // current config
const fileExtensions = ['.pdf', '.txt', '.json', '.md']; // from files.extensions

console.log('Testing file validation with CURRENT config (empty contentTypes):');
testFiles.forEach(file => {
    const result = checkFileIsValid(file, emptyContentTypes, fileExtensions);
    console.log(`  ${file.name}: ${result ? 'PASS' : 'FAIL'}`);
});
EOF

Repository: TryGhost/Ghost

Length of output: 2562


Line 97: Empty uploads.files.contentTypes breaks all file uploads.

The validation logic requires BOTH a matching MIME type AND a matching extension (AND operator). With an empty contentTypes array, this check will reject all file uploads, making the /files/upload endpoint non-functional. Either populate contentTypes with valid MIME types or change the validation logic to extension-only checking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ghost/core/core/shared/config/overrides.json` at line 97, The empty
uploads.files.contentTypes array causes the upload validator to always fail (it
requires both MIME type and extension), so either populate
uploads.files.contentTypes with the expected MIME types for your allowed
extensions (e.g., image/png, image/jpeg, video/mp4, etc.) or update the upload
validation logic to treat an empty contentTypes as a wildcard/skip for MIME
checking (i.e., if uploads.files.contentTypes.length === 0, validate by
extension only or allow any MIME), ensuring the /files/upload validation path is
updated accordingly (adjust the validator used by the upload handler).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The key detail you missed is that the /files/upload route uses fileValidation, not the generic validation middleware. Looking at the route:

    router.post('/files/upload',
        mw.authAdminApi,
        apiMw.upload.single('file'),
        apiMw.upload.fileValidation({type: 'files'}),
        http(api.files.upload)
    );

The fileValidation middleware (lines 373-397) only checks extensions — it never reads contentTypes at all. The empty contentTypes: [] in overrides.json exists solely to prevent undefined from leaking into the ImportManager (which aggregates content types from all upload handlers via lodash.flatMap). It has zero impact on the upload validation path.

The older validation() middleware is the one that checks both extensions and content types, but that's used by images/media — not files.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vershwal, you're absolutely right — I missed that distinction. The /files/upload route wires fileValidation (extension-only), not the generic validation middleware that performs the AND-check on both MIME types and extensions. The empty contentTypes: [] is purely defensive, keeping ImportManager's lodash.flatMap aggregation safe from undefined rather than having any effect on the upload validation path itself. My concern was unfounded — apologies for the noise!

( ˘ᵕ˘) scribbles notes furiously


✏️ Learnings added
Learnt from: vershwal
Repo: TryGhost/Ghost PR: 26869
File: ghost/core/core/shared/config/overrides.json:97-97
Timestamp: 2026-03-18T13:42:05.540Z
Learning: In TryGhost/Ghost (`ghost/core`), the `/files/upload` route uses `apiMw.upload.fileValidation({type: 'files'})` (defined in `ghost/core/core/server/web/api/middleware/upload.js`, lines ~373-397), which performs **extension-only** validation and never reads the `contentTypes` field from the upload config. The empty `contentTypes: []` for `uploads.files` in `ghost/core/core/shared/config/overrides.json` is intentional: it prevents `undefined` from being spread into the `ImportManager`, which aggregates content types across all upload handlers via `lodash.flatMap`. The older `validation()` middleware (which ANDs MIME type + extension) is only used for images/media uploads, not files.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@vershwal vershwal merged commit e3b6d4f into main Mar 19, 2026
33 checks passed
@vershwal vershwal deleted the fileValidation branch March 19, 2026 11:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants