Description
Affected Version
This vulnerability is specific to AdminJS Fastify v4.1.3, identified in this commit. For relevant documentation, see adminjs-options.interface.ts and core-scripts.interface.ts.
Problem Overview
The AdminJSOptions
assets
configuration allows customization of styles, scripts, and core scripts for AdminJS. However, the withProtectedRoutesHandler
function in the affected version fails to handle assets
correctly. The issues are twofold:
- Function Ignored: If
admin.options?.assets
is a function, its returned values are not processed. - Improper Flattening: Objects such as
coreScripts
inadmin.options?.assets
are incorrectly flattened usingArray.prototype.flat()
, causing privilege escalation by unintentionally granting unauthenticated API access.
Exploitation Scenario
Consider the following AdminJSOptions
configuration:
{
assetsCDN: 'https://example.com/',
assets: {
styles: ['/path/to/style.css'],
coreScripts: {
'app.bundle.js': 'app.bundle.123456.js',
'components.bundle.js': 'components.bundle.123456.js',
'design-system.bundle.js': 'design-system.bundle.123456.js',
'global.bundle.js': 'global.bundle.123456.js',
},
},
}
and the affected code with some logs
const assets = [
...AdminRouter.assets.map((a) => a.path),
...Object.values(admin.options?.assets ?? {}).flat(),
];
console.log('AdminRouter.assets.map((a) => a.path) ->', AdminRouter.assets.map((a) => a.path))
console.log('Object.values(admin.options?.assets ?? {}).flat() ->', Object.values(admin.options?.assets ?? {}).flat())
console.log('assets ->', assets)
assets.find((a) => {
console.log(' asset ->', a)
console.log(' result ->', request.url.match(a))
return request.url.match(a);
})
console.log('assets.find((a) => request.url.match(a)) -> ', assets.find((a) => request.url.match(a)))
if (assets.find((a) => request.url.match(a))) {
console.log(1)
return;
}
When making an API request, such as:
curl -X POST 'http://localhost:8000/admin/api/resources/users/records/23/show'
The logs show that the coreScripts
object is incorrectly treated as valid:
AdminRouter.assets.map((a) => a.path) -> [
'/frontend/assets/icomoon.css',
'/frontend/assets/icomoon.eot',
'/frontend/assets/icomoon.svg',
'/frontend/assets/icomoon.ttf',
'/frontend/assets/icomoon.woff',
'/frontend/assets/app.bundle.js',
'/frontend/assets/global.bundle.js',
'/frontend/assets/design-system.bundle.js',
'/frontend/assets/logo.svg',
'/frontend/assets/logo-mini.svg',
'/frontend/assets/themes/dark/theme.bundle.js',
'/frontend/assets/themes/dark/style.css',
'/frontend/assets/themes/light/theme.bundle.js',
'/frontend/assets/themes/light/style.css',
'/frontend/assets/themes/no-sidebar/theme.bundle.js',
'/frontend/assets/themes/no-sidebar/style.css'
]
Object.values(admin.options?.assets ?? {}).flat() -> [
'/path/to/style.css',
{
'app.bundle.js': 'app.bundle.123456.js',
'components.bundle.js': 'components.bundle.123456.js',
'design-system.bundle.js': 'design-system.bundle.123456.js',
'global.bundle.js': 'global.bundle.123456.js'
}
]
assets -> [
'/frontend/assets/icomoon.css',
'/frontend/assets/icomoon.eot',
'/frontend/assets/icomoon.svg',
'/frontend/assets/icomoon.ttf',
'/frontend/assets/icomoon.woff',
'/frontend/assets/app.bundle.js',
'/frontend/assets/global.bundle.js',
'/frontend/assets/design-system.bundle.js',
'/frontend/assets/logo.svg',
'/frontend/assets/logo-mini.svg',
'/frontend/assets/themes/dark/theme.bundle.js',
'/frontend/assets/themes/dark/style.css',
'/frontend/assets/themes/light/theme.bundle.js',
'/frontend/assets/themes/light/style.css',
'/frontend/assets/themes/no-sidebar/theme.bundle.js',
'/frontend/assets/themes/no-sidebar/style.css',
'/path/to/style.css',
{
'app.bundle.js': 'app.bundle.123456.js',
'components.bundle.js': 'components.bundle.123456.js',
'design-system.bundle.js': 'design-system.bundle.123456.js',
'global.bundle.js': 'global.bundle.123456.js'
}
]
asset -> /frontend/assets/icomoon.css
result -> null
asset -> /frontend/assets/icomoon.eot
result -> null
asset -> /frontend/assets/icomoon.svg
result -> null
asset -> /frontend/assets/icomoon.ttf
result -> null
asset -> /frontend/assets/icomoon.woff
result -> null
asset -> /frontend/assets/app.bundle.js
result -> null
asset -> /frontend/assets/global.bundle.js
result -> null
asset -> /frontend/assets/design-system.bundle.js
result -> null
asset -> /frontend/assets/logo.svg
result -> null
asset -> /frontend/assets/logo-mini.svg
result -> null
asset -> /frontend/assets/themes/dark/theme.bundle.js
result -> null
asset -> /frontend/assets/themes/dark/style.css
result -> null
asset -> /frontend/assets/themes/light/theme.bundle.js
result -> null
asset -> /frontend/assets/themes/light/style.css
result -> null
asset -> /frontend/assets/themes/no-sidebar/theme.bundle.js
result -> null
asset -> /frontend/assets/themes/no-sidebar/style.css
result -> null
asset -> /path/to/style.css
result -> null
asset -> {
'app.bundle.js': 'app.bundle.123456.js',
'components.bundle.js': 'components.bundle.123456.js',
'design-system.bundle.js': 'design-system.bundle.123456.js',
'global.bundle.js': 'global.bundle.123456.js'
}
result -> [
'e',
index: 12,
input: '/admin/api/resources/users/records/23/show',
groups: undefined
]
assets.find((a) => request.url.match(a)) -> {
'app.bundle.js': 'app.bundle.123456.js',
'components.bundle.js': 'components.bundle.123456.js',
'design-system.bundle.js': 'design-system.bundle.123456.js',
'global.bundle.js': 'global.bundle.123456.js'
}
1
Here, 1
indicates the request was validated, granting access without proper authentication.
Root Cause
In the preHandler
hook of withProtectedRoutesHandler
, the assets
array includes improperly flattened values:
const assets = [
...AdminRouter.assets.map((a) => a.path),
...Object.values(admin.options?.assets ?? {}).flat(),
];
This causes objects like coreScripts
to bypass route protection.
Proposed Fix
The updated code ensures proper handling of admin.options?.assets
, respects functions, and validates only string-based assets:
import AdminJS, { CurrentAdmin, Router as AdminRouter } from 'adminjs';
import { FastifyInstance } from 'fastify';
export const withProtectedRoutesHandler = (
fastifyApp: FastifyInstance,
admin: AdminJS,
): void => {
const { rootPath } = admin.options;
fastifyApp.addHook('preHandler', async (request, reply) => {
const buildComponentRoute = AdminRouter.routes.find(
(r) => r.action === 'bundleComponents',
)?.path;
let AdminOptionsAssets = admin.options?.assets ?? {};
if (typeof AdminOptionsAssets === 'function')
AdminOptionsAssets = await AdminOptionsAssets(request.session.get('adminUser') as CurrentAdmin);
const assets = [
...AdminRouter.assets.map((a) => a.path),
...Object.values(AdminOptionsAssets).flat(),
];
if (assets.find((a) => typeof a === 'string' && request.url.match(a))) {
return;
} else if (buildComponentRoute && request.url.match(buildComponentRoute)) {
return;
} else if (
!request.url.startsWith(rootPath) ||
request.session.get('adminUser') ||
// these routes don't need authentication
request.url.startsWith(admin.options.loginPath) ||
request.url.startsWith(admin.options.logoutPath)
) {
return;
} else {
// If the redirection is caused by API call to some action just redirect to resource
const [redirectTo] = request.url.split('/actions');
request.session.redirectTo = redirectTo.includes(`${rootPath}/api`)
? rootPath
: redirectTo;
return reply.redirect(admin.options.loginPath);
}
});
};
Fix Highlights
- Function Respect: Functions in assets are executed and their return values are processed.
- Validation: Only string-based paths are included in assets, preventing objects like coreScripts from being validated.
- Secure Access: Unauthenticated API calls are blocked, ensuring route protection.
Impact
The fix eliminates privilege escalation risks by:
- Validating assets paths correctly.
- Blocking unauthenticated access to protected API routes.