Skip to content

fix: require admin session to download AdminJS PII exports - #2337

Open
ae2079 wants to merge 1 commit into
stagingfrom
fix/admin-download-auth
Open

fix: require admin session to download AdminJS PII exports#2337
ae2079 wants to merge 1 commit into
stagingfrom
fix/admin-download-auth

Conversation

@ae2079

@ae2079 ae2079 commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Problem

app.get('/admin/download/:filename', ...) in src/server/bootstrap.ts is unauthenticated. It is registered as a plain Express route before and outside the AdminJS authenticated router (app.use(adminJsRootPath, await getAdminJsRouter())), so Express matches it first and never runs any auth check.

The route serves the CSV exports written by the AdminJS projects tab to src/server/adminJs/tabs/exports — these contain PII (project/donor email addresses). As a result, anyone on the internet could download them, e.g. GET /admin/download/emails.csv.

Note: path-traversal on this route was already fixed previously (path.basename + a containment check). That logic is preserved here unchanged — this PR only adds authentication.

Fix

Extract the handler into createDownloadAdminJsExportHandler (new src/server/adminJsExportDownload.ts). It now requires a valid AdminJS admin session via getCurrentAdminJsSession and returns 401 otherwise:

  • getCurrentAdminJsSession throws a TypeError when the request carries no cookie header (cookie.parse(undefined)), so the call is wrapped in try/catch and both thrown errors and falsy results are treated as unauthenticated.
  • The auth check runs before any file resolution, so unauthenticated callers learn nothing about the exports directory.
  • The existing path-traversal protection (path.basename + path.dirname(filePath) !== exportsDir) is copied verbatim.

The session resolver is passed in (dependency injection) rather than imported directly, so the handler can be unit-tested without booting Redis/Postgres/AdminJS. bootstrap.ts wires in the real getCurrentAdminJsSession; the now-unused path import was removed.

The legitimate flow is unchanged: when an admin exports from the AdminJS panel, the browser follows the /admin/download/... redirect with the adminjs session cookie, so getCurrentAdminJsSession resolves and the file downloads.

Tests

New src/server/adminJsExportDownload.test.ts (hermetic — no DB/Redis), all passing:

  • No admin session → 401, res.download not called.
  • Session lookup throws (no cookie header) → 401, res.download not called.
  • Valid admin session → file served from the exports dir, no 401.

Verification

  • npx tsc --noEmit — clean across the project.
  • eslint — clean (also enforced by the husky pre-commit hook).
  • New test suite passes.

A full live end-to-end run (real server + cookies) wasn't possible in my environment (no Postgres/Redis available), so behaviour is covered by the unit tests above plus the type/lint checks.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added secure download endpoint for AdminJS CSV exports with mandatory admin authentication and built-in path traversal protection.
  • Tests

    • Added comprehensive test suite verifying authentication requirements and security validations for export downloads.
  • Refactor

    • Integrated new export handler into server bootstrap, replacing inline implementation with dedicated secure module.

The GET /admin/download/:filename route serves project/donor
email-address CSV exports (PII) from src/server/adminJs/tabs/exports. It
is registered as a plain Express route, before and outside the AdminJS
authenticated router, so it never inherited any auth check — anyone on
the internet could download the files.

Extract the handler into createDownloadAdminJsExportHandler, which now
requires a valid AdminJS admin session (via getCurrentAdminJsSession) and
returns 401 otherwise. getCurrentAdminJsSession throws when the request
has no cookie header, so both thrown errors and falsy results are treated
as unauthenticated. The existing path-traversal protection is preserved
unchanged.

The session resolver is injected so the handler can be unit-tested
without Redis/DB; adds tests for the no-session, throwing-session, and
valid-admin cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread src/server/bootstrap.ts
// itself (see createDownloadAdminJsExportHandler).
app.get(
'/admin/download/:filename',
createDownloadAdminJsExportHandler(getCurrentAdminJsSession),
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR extracts AdminJS CSV export download logic from inline bootstrap code into a dedicated handler module with dependency injection, path validation, and unit tests. The handler enforces admin authentication, prevents directory traversal, and is wired into the /admin/download/:filename route.

Changes

AdminJS Export Download Handler

Layer / File(s) Summary
Download handler types and implementation
src/server/adminJsExportDownload.ts
Defines AdminJsSessionResolver type for injected session resolution, sets exportsDir relative to module, and implements createDownloadAdminJsExportHandler factory that authenticates via injected session resolver, validates filenames against path traversal with basename normalization, and serves files using res.download().
Handler test coverage
src/server/adminJsExportDownload.test.ts
Tests authentication enforcement (401 on missing/invalid session) and successful file downloads (verified file path format) using Mocha/Chai/Sinon stubbed Express request/response objects.
Bootstrap route integration
src/server/bootstrap.ts
Removes unused path import, adds imports for getCurrentAdminJsSession and createDownloadAdminJsExportHandler, and replaces inline /admin/download/:filename handler with factory call.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A download handler hops into place,
With auth checks keeping hackers at bay,
Path traversal blocked without a trace,
Tests verify the exports display,
CSV files fly safely today! 📥

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: require admin session to download AdminJS PII exports' directly and clearly summarizes the main change: adding authentication requirements to the AdminJS export download route to protect PII.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/admin-download-auth

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install timed out. The project may have too many dependencies for the sandbox.


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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/server/adminJsExportDownload.test.ts (1)

31-69: ⚡ Quick win

Add a regression test for traversal-style filenames.

The suite covers the auth branches, but it never exercises the 400 Invalid filename path in src/server/adminJsExportDownload.ts Lines 49-55. Since that guard protects a PII export route, add a case like ../secrets.csv (or ..) and assert 400 plus no download() call.

Proposed test
+  it('rejects traversal-style filenames', async () => {
+    const handler = createDownloadAdminJsExportHandler(
+      sinon.stub().resolves({ id: 1 }),
+    );
+    const res = buildRes();
+
+    await handler(buildReq('../secrets.csv'), res);
+
+    assert.isTrue(res.status.calledOnceWith(400));
+    assert.isTrue(res.send.calledOnceWith('Invalid filename'));
+    assert.isFalse(res.download.called);
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/adminJsExportDownload.test.ts` around lines 31 - 69, Add a test
exercising the filename validation in createDownloadAdminJsExportHandler by
calling the handler with a traversal-style filename (e.g.
buildReq('../secrets.csv') or buildReq('..')) while supplying a stubbed valid
session (sinon.stub().resolves({ id: 1 })) and a buildRes() response; then
assert the handler responds with status 400 and does not call res.download (and
optionally assert res.send was called with the "Invalid filename" message) to
ensure the 400 branch (the guard in createDownloadAdminJsExportHandler) is
covered.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/server/bootstrap.ts`:
- Around line 220-223: The /admin/download/:filename route is registered before
the global limiter and falls under the broad /admin bypass, leaving
createDownloadAdminJsExportHandler(getCurrentAdminJsSession) unthrottled; add a
narrow rate limiter middleware specifically to this route (or adjust the global
exempt logic to exclude paths starting with /admin/download/) so requests
hitting getCurrentAdminJsSession do not bypass throttling. Locate the route
registration that calls createDownloadAdminJsExportHandler and wrap it with the
new per-route limiter middleware (or modify the /admin bypass check to skip
"/admin/download/") and ensure the limiter configuration is conservative (low
burst and reasonable window) to prevent Redis/findUserById fan-out on repeated
unauthorized hits.

---

Nitpick comments:
In `@src/server/adminJsExportDownload.test.ts`:
- Around line 31-69: Add a test exercising the filename validation in
createDownloadAdminJsExportHandler by calling the handler with a traversal-style
filename (e.g. buildReq('../secrets.csv') or buildReq('..')) while supplying a
stubbed valid session (sinon.stub().resolves({ id: 1 })) and a buildRes()
response; then assert the handler responds with status 400 and does not call
res.download (and optionally assert res.send was called with the "Invalid
filename" message) to ensure the 400 branch (the guard in
createDownloadAdminJsExportHandler) is covered.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f3de05c4-3f2f-4283-b279-c54f11f0c5e9

📥 Commits

Reviewing files that changed from the base of the PR and between b6718ee and fc5338f.

📒 Files selected for processing (3)
  • src/server/adminJsExportDownload.test.ts
  • src/server/adminJsExportDownload.ts
  • src/server/bootstrap.ts

Comment thread src/server/bootstrap.ts
Comment on lines +220 to +223
app.get(
'/admin/download/:filename',
createDownloadAdminJsExportHandler(getCurrentAdminJsSession),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Rate-limit this public download endpoint separately.

Line 220 registers the route before the global limiter, and Lines 266-269 later exempt every /admin request anyway. That leaves /admin/download/:filename unthrottled even though each hit now runs getCurrentAdminJsSession(), which can fan out to Redis and findUserById (src/server/adminJs/adminJs.ts:101-130) before returning 401. Add a narrow limiter here, or exclude /admin/download/ from the /admin bypass.

🧰 Tools
🪛 GitHub Check: CodeQL

[failure] 222-222: Missing rate limiting
This route handler performs a file system access, but is not rate-limited.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/bootstrap.ts` around lines 220 - 223, The
/admin/download/:filename route is registered before the global limiter and
falls under the broad /admin bypass, leaving
createDownloadAdminJsExportHandler(getCurrentAdminJsSession) unthrottled; add a
narrow rate limiter middleware specifically to this route (or adjust the global
exempt logic to exclude paths starting with /admin/download/) so requests
hitting getCurrentAdminJsSession do not bypass throttling. Locate the route
registration that calls createDownloadAdminJsExportHandler and wrap it with the
new per-route limiter middleware (or modify the /admin bypass check to skip
"/admin/download/") and ensure the limiter configuration is conservative (low
burst and reasonable window) to prevent Redis/findUserById fan-out on repeated
unauthorized hits.

Source: Linters/SAST tools

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.

2 participants