Skip to content

Commit 50a3142

Browse files
committed
chore(release): merge develop/v1 with CSP security config, new utils, and README updates
- Added content security policy (CSP) configuration for Helmet. - Added `isValidDomain` utility with unit tests. - Enhanced `view-engine` asset handling for external CDN. - Improved token refresh validation logic. - Updated README with RESTful API documentation and Postman image. - Bumped express-validator and validator dependencies. - Minor layout fixes for admin and user views. - Extended .gitignore to include .env.* files.
2 parents 405918e + 6b5c859 commit 50a3142

File tree

14 files changed

+161
-45
lines changed

14 files changed

+161
-45
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ storage/
55
notes/
66
logs/
77
.env
8+
.env.*
89
*.bak.*

README.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
![GitHub License](https://img.shields.io/github/license/agoenks29D/exzly)
1010
![GitHub Code Size](https://img.shields.io/github/languages/code-size/agoenks29D/exzly)
1111

12-
[![CI - Setup, Test & Coverage](https://github.com/agoenks29D/exzly/actions/workflows/ci.yml/badge.svg?branch=develop)](https://github.com/agoenks29D/exzly/actions/workflows/ci.yml)
12+
[![CI - Setup, Test & Coverage](https://github.com/agoenks29D/exzly/actions/workflows/ci.yml/badge.svg)](https://github.com/agoenks29D/exzly/actions/workflows/ci.yml)
1313
[![codecov](https://codecov.io/gh/agoenks29D/exzly/branch/main/graph/badge.svg?token=7UVW9XHW3Y)](https://codecov.io/gh/agoenks29D/exzly)
1414

1515
## 📖 Description
@@ -36,7 +36,7 @@ Whether you're creating a business platform, a complex service, or a company-gra
3636
- [🧹 Linter and Formatter](#-linter-and-formatter)
3737
- [🚀 Running the Project](#-running-the-project)
3838
- [🧪 Running Tests](#-running-tests)
39-
- [📬 API Documentation (Postman)](#-api-documentation-postman)
39+
- [📬 RESTful API Documentation](#-restful-api-documentation)
4040
- [👤 Default Account](#-default-account)
4141
- [📄 License](#-license)
4242

@@ -204,7 +204,6 @@ Handle database migrations and seeders as follows:
204204
```
205205

206206
- **Run specific seeders:**
207-
208207
- Start seeder (used for production, no fake data):
209208

210209
```bash
@@ -277,11 +276,11 @@ Run test coverage:
277276
npm run test:cov
278277
```
279278

280-
## 📬 API Documentation (Postman)
279+
## 📬 RESTful API Documentation
281280

282-
You can explore and test all available API endpoints using our public Postman documentation:
281+
You can explore and test all available API endpoints using our Postman documentation:
283282

284-
👉 [View Exzly Postman Collection](https://www.postman.com/medansoftware/public-projects/collection/sp6dra0/exzly)
283+
[<p align="center"><img src="images/logo/Postman.png" alt="Postman Logo" width="200"><br><b>Visit the Exzly Postman Collection</b></p>](https://www.postman.com/medansoftware/open-source/collection/sp6dra0/exzly)
285284

286285
## 👤 Default Account
287286

__tests__/utils.spec.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const {
66
byteFormat,
77
getFileTypeFromBuffer,
88
getFileTypeFromFile,
9+
isNumeric,
910
} = require('@exzly-utils');
1011

1112
const loadSample = (fileName) => path.join(process.cwd(), '__tests__/samples', fileName);
@@ -97,6 +98,38 @@ describe('Utils', () => {
9798
});
9899
});
99100

101+
describe('isNumeric', () => {
102+
it('should return true for integers', () => {
103+
expect(isNumeric(123)).toBe(true);
104+
expect(isNumeric(0)).toBe(true);
105+
expect(isNumeric(-5)).toBe(true);
106+
});
107+
108+
it('should return true for numeric strings (only digits)', () => {
109+
expect(isNumeric('456')).toBe(true);
110+
expect(isNumeric('0')).toBe(true);
111+
});
112+
113+
it('should return false for floats/decimals', () => {
114+
expect(isNumeric(12.3)).toBe(false);
115+
expect(isNumeric(-1.5)).toBe(false);
116+
});
117+
118+
it('should return false for strings containing non-digits', () => {
119+
expect(isNumeric('123a')).toBe(false);
120+
expect(isNumeric('4.5')).toBe(false);
121+
expect(isNumeric('')).toBe(false);
122+
});
123+
124+
it('should return false for non-numeric types', () => {
125+
expect(isNumeric(null)).toBe(false);
126+
expect(isNumeric(undefined)).toBe(false);
127+
expect(isNumeric(true)).toBe(false);
128+
expect(isNumeric({})).toBe(false);
129+
expect(isNumeric([])).toBe(false);
130+
});
131+
});
132+
100133
describe('getFileTypeFromBuffer', () => {
101134
it('should return the correct file type for a known buffer', async () => {
102135
const pngHeader = Buffer.from([

images/logo/Postman.png

13.2 KB
Loading

package-lock.json

Lines changed: 8 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/config/security.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,28 @@
1+
/**
2+
* @typedef {import('express').Request} Request
3+
* @typedef {import('express').Response} Response
4+
* @typedef {import('express').NextFunction} NextFunction
5+
* @typedef {(req: Request, res: Response, next: NextFunction) => string} NonceFunction
6+
*/
7+
8+
/**
9+
* @typedef {Object} CSPDirectives
10+
* @property {(string|NonceFunction)[]} defaultSrc - Default content sources. Restricts all resources to the same origin.
11+
* @property {(string|NonceFunction)[]} imgSrc - Allowed image sources. Includes self, data URIs, and trusted CDNs.
12+
* @property {(string|NonceFunction)[]} scriptSrc - Allowed JavaScript sources. Supports nonces for inline scripts.
13+
* @property {(string|NonceFunction)[]} scriptSrcElem - Allowed sources for script elements.
14+
* @property {(string|NonceFunction)[]} scriptSrcAttr - Allowed sources for inline script attributes.
15+
* @property {string} [reportUri] - Optional URI endpoint for reporting CSP violations.
16+
*/
17+
18+
/**
19+
* @typedef {Object} ContentSecurityPolicy
20+
* @property {boolean} useDefaults - Enables Helmet’s default CSP directives.
21+
* @property {CSPDirectives} directives - Custom-defined CSP directives that specify allowed content sources.
22+
*/
23+
24+
const { isValidDomain } = require('@exzly-utils');
25+
126
/**
227
* Allowed MIME types for image files.
328
* These are the supported formats for image uploads.
@@ -91,9 +116,47 @@ const rateLimit = {
91116
forgotPasswordRateLimitDuration: '10m', // default: 10 minutes
92117
};
93118

119+
/**
120+
* Content Security Policy (CSP) configuration.
121+
* This policy defines the sources from which content can be loaded, helping to mitigate
122+
* cross-site scripting (XSS), data injection, and other code execution attacks.
123+
*
124+
* The configuration follows Helmet's CSP middleware format and can be customized
125+
* based on the application's asset sources and security needs.
126+
*
127+
* @type {ContentSecurityPolicy}
128+
*/
129+
const contentSecurityPolicy = {
130+
useDefaults: true,
131+
directives: {
132+
defaultSrc: ["'self'"],
133+
imgSrc: [
134+
'data:',
135+
"'self'",
136+
'https://picsum.photos',
137+
'https://loremflickr.com',
138+
'https://fastly.picsum.photos',
139+
'https://cdn.jsdelivr.net',
140+
],
141+
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
142+
scriptSrcElem: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
143+
scriptSrcAttr: [(req, res) => `'nonce-${res.locals.nonce}'`],
144+
// reportUri: `${process.env.API_ROUTE}/csp-violation-report`,
145+
},
146+
};
147+
148+
if (isValidDomain(process.env.ASSETS_URL)) {
149+
const CDN_URL = process.env.ASSETS_URL;
150+
contentSecurityPolicy.directives.imgSrc.push(CDN_URL);
151+
contentSecurityPolicy.directives.scriptSrc.push(CDN_URL);
152+
contentSecurityPolicy.directives.scriptSrcElem.push(CDN_URL);
153+
contentSecurityPolicy.directives.scriptSrcAttr.push(CDN_URL);
154+
}
155+
94156
module.exports = {
95157
allowedImageMimeTypes,
96158
refreshTokenExpires,
97159
passwordResetExpires,
98160
rateLimit,
161+
contentSecurityPolicy,
99162
};

src/middlewares/view-engine.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const lodash = require('lodash');
1111
const nunjucks = require('nunjucks');
1212
const httpErrors = require('http-errors');
1313
const { viewEngineHelper } = require('@exzly-helpers');
14-
const { getPackageJSON, createRoute } = require('@exzly-utils');
14+
const { getPackageJSON, createRoute, isValidDomain } = require('@exzly-utils');
1515

1616
/**
1717
* View engine
@@ -74,7 +74,15 @@ module.exports = (express) => {
7474
);
7575
viewEngine.addGlobal('assetsUrl', (assetPath) => {
7676
const assetsURL = process.env.ASSETS_URL || '/';
77-
return `${assetsURL}${assetPath}`.replace(/\/{2,}/g, '/');
77+
78+
assetPath = `${assetPath}`.replace(/\/{2,}/g, '/');
79+
80+
if (isValidDomain(assetsURL)) {
81+
assetPath = assetPath.replace(/^\/public/, '/').replace(/\/{2,}/g, '/');
82+
return assetsURL + assetPath;
83+
}
84+
85+
return assetPath;
7886
});
7987

8088
/**

src/routes/api/auth.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,11 +151,22 @@ app.post(
151151
app.post(
152152
'/refresh-token',
153153
[authValidator.refreshToken],
154-
asyncRoute(async (req, res) => {
154+
asyncRoute(async (req, res, next) => {
155155
const { refreshToken } = matchedData(req, { locations: ['body'] });
156+
const findRefreshToken = AuthTokenModel.findOne({
157+
where: { token: refreshToken, type: 'refresh-token' },
158+
});
156159
const { userId } = jwtDecode(refreshToken);
157-
const accessToken = jwtHelper.createUserToken('access-token', userId);
158160

161+
if (!findRefreshToken) {
162+
return next(httpErrors.Unauthorized('Invalid token'));
163+
}
164+
165+
if (findRefreshToken.isRevoked) {
166+
return next(httpErrors.Unauthorized('Token was revoked'));
167+
}
168+
169+
const accessToken = jwtHelper.createUserToken('access-token', userId);
159170
await AuthTokenModel.create({ type: 'access-token', token: accessToken });
160171

161172
// send response

src/routes/index.js

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const morgan = require('morgan');
44
const express = require('express');
55
const compression = require('compression');
66
const { unless } = require('express-unless');
7+
const { securityConfig } = require('@exzly-config');
78
const {
89
viewEngineMiddleware,
910
fileLoaderMiddleware,
@@ -20,23 +21,7 @@ const adminErrorHandler = require('./admin/error');
2021
const app = express();
2122

2223
const helmetMiddleware = helmet({
23-
contentSecurityPolicy: {
24-
useDefaults: true,
25-
directives: {
26-
defaultSrc: ["'self'"],
27-
imgSrc: [
28-
'data:',
29-
"'self'",
30-
'https://picsum.photos',
31-
'https://loremflickr.com',
32-
'https://fastly.picsum.photos',
33-
'https://cdn.jsdelivr.net',
34-
],
35-
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.nonce}'`],
36-
scriptSrcAttr: [(req, res) => `'nonce-${res.locals.nonce}'`],
37-
// reportUri: `${process.env.API_ROUTE}/csp-violation-report`,
38-
},
39-
},
24+
contentSecurityPolicy: securityConfig.contentSecurityPolicy,
4025
});
4126

4227
helmetMiddleware.unless = unless;
@@ -53,7 +38,11 @@ app.set('view engine', 'njk');
5338
app.use(cors());
5439
app.use(
5540
morgan('dev', {
56-
skip: () => process.env.NODE_ENV !== 'development',
41+
skip: (req) => {
42+
const byRegex = /^\/public\//;
43+
const isProduction = process.env.NODE_ENV !== 'development';
44+
return isProduction || byRegex.test(req.originalUrl);
45+
},
5746
}),
5847
);
5948
app.use(compression());

src/utils/string.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,15 @@ const randomString = (
4545
return str;
4646
};
4747

48-
module.exports = { maskEmail, randomString };
48+
/**
49+
* Validate if a string is a valid domain.
50+
*
51+
* @param {string} domain - The domain to be validated.
52+
* @returns {boolean} true if the domain is valid, false otherwise.
53+
*/
54+
const isValidDomain = (domain) => {
55+
const regex = /^(https?:\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/[^\s]*)?$/;
56+
return regex.test(domain);
57+
};
58+
59+
module.exports = { maskEmail, randomString, isValidDomain };

0 commit comments

Comments
 (0)