Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions stackpress/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,19 @@
"import": "./esm/language/index.js"
},

"./csrf": {
"require": "./cjs/index.js",
"import": "./esm/index.js"
},
"./csrf/plugin": {
"require": "./cjs/csrf/plugin.js",
"import": "./esm/csrf/plugin.js"
},
"./csrf/types": {
"require": "./cjs/csrf/types.js",
"import": "./esm/csrf/types.js"
},

"./AbstractSchema": {
"require": "./cjs/schema/interface/AbstractSchema.js",
"import": "./esm/schema/interface/AbstractSchema.js"
Expand Down
12 changes: 12 additions & 0 deletions stackpress/src/admin/transform/pages/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ export default function generate(directory: Directory, model: Model) {
moduleSpecifier: 'stackpress/admin/types',
namedImports: [ 'AdminConfig' ]
});
//import type { CsrfPlugin } from '../../types.js';
source.addImportDeclaration({
isTypeOnly: true,
moduleSpecifier: '../../types.js',
namedImports: [ 'CsrfPlugin' ]
});

//------------------------------------------------------------------//
// Import Client
Expand Down Expand Up @@ -103,6 +109,10 @@ const language = ctx.config.path<LanguageConfig>('language', {
locale: 'en_US',
languages: {}
});
//get the csrf plugin
const csrf = ctx.plugin<CsrfPlugin>('csrf');
//generate token
csrf.generateToken(res, ctx);
const admin = ctx.config.path<AdminConfig>('admin', {});
//set data for template layer
res.data.set('view', {
Expand All @@ -128,6 +138,8 @@ res.data.set('admin', {

//if form submitted
if (req.method === 'POST') {
//validate csrf
if (!csrf.validateToken(req, res)) return;
//emit the create event
const response = await ctx.resolve<<%type%>>('<%event%>-create', req, res);
//if error
Expand Down
6 changes: 6 additions & 0 deletions stackpress/src/admin/transform/pages/detail/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ if (res.body || (res.code && res.code !== 200)) {
//let the response pass through
return;
}
//get csrf plugin
const csrf = ctx.plugin<CsrfPlugin>('csrf');
//generate token
csrf.generateToken(res, ctx);
//get the view, brandm lang and admin config
const view = ctx.config.path<ViewConfig>('view', {});
const brand = ctx.config.path<BrandConfig>('brand', {});
Expand Down Expand Up @@ -183,6 +187,8 @@ if (detail.code !== 200) {

//if form submitted
if (req.method === 'POST') {
//validate csrf
if (!csrf.validateToken(req, res)) return;
//get the form input
const input = req.data();
//set the foreign id
Expand Down
23 changes: 23 additions & 0 deletions stackpress/src/admin/transform/pages/remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ export default function generate(directory: Directory, model: Model) {
moduleSpecifier: 'stackpress/admin/types',
namedImports: [ 'AdminConfig' ]
});
//import type { CsrfPlugin } from '../../types.js';
source.addImportDeclaration({
isTypeOnly: true,
moduleSpecifier: '../../types.js',
namedImports: [ 'CsrfPlugin' ]
});

//------------------------------------------------------------------//
// Import Client
Expand Down Expand Up @@ -75,6 +81,10 @@ if (res.body || (res.code && res.code !== 200)) {
//let the response pass through
return;
}
//get csrf plugin
const csrf = ctx.plugin<CsrfPlugin>('csrf');
//generate token
csrf.generateToken(res, ctx);
//get the view, brandm lang and admin config
const view = ctx.config.path<ViewConfig>('view', {});
const brand = ctx.config.path<BrandConfig>('brand', {});
Expand Down Expand Up @@ -108,6 +118,19 @@ res.data.set('admin', {

//if confirmed
if (req.data('confirmed')) {
//validate csrf
if (!csrf.validateToken(req, res)) {
res.session.set('flash', JSON.stringify({
type: 'error',
message: 'This page may have been requested from an external source. ' +
'We corrected the issue. Please try again.',
close: 2000
}));
const base = admin.base ?? '/admin';
const id = req.data('id');
res.redirect(\`\${base}/<%model%>/remove/\${id}\`);
return;
};
//emit remove event
await ctx.emit('<%event%>-remove', req, res);
//if OK
Expand Down
23 changes: 23 additions & 0 deletions stackpress/src/admin/transform/pages/restore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ export default function generate(directory: Directory, model: Model) {
moduleSpecifier: 'stackpress/admin/types',
namedImports: [ 'AdminConfig' ]
});
//import type { CsrfPlugin } from '../../types.js';
source.addImportDeclaration({
isTypeOnly: true,
moduleSpecifier: '../../types.js',
namedImports: [ 'CsrfPlugin' ]
});

//------------------------------------------------------------------//
// Import Client
Expand Down Expand Up @@ -81,6 +87,10 @@ if (res.body || (res.code && res.code !== 200)) {
//let the response pass through
return;
}
//get csrf plugin
const csrf = ctx.plugin<CsrfPlugin>('csrf');
//generate token
csrf.generateToken(res, ctx);
//get the view, brandm lang and admin config
const view = ctx.config.path<ViewConfig>('view', {});
const brand = ctx.config.path<BrandConfig>('brand', {});
Expand Down Expand Up @@ -120,6 +130,19 @@ res.data.set('admin', {

//if confirmed
if (req.data('confirmed')) {
//validate csrf
if (!csrf.validateToken(req, res)) {
res.session.set('flash', JSON.stringify({
type: 'error',
message: 'This page may have been requested from an external source. ' +
'We corrected the issue. Please try again.',
close: 2000
}));
const base = admin.base ?? '/admin';
const id = req.data('id');
res.redirect(\`\${base}/<%model%>/restore/\${id}\`);
return;
};
//emit restore event
await ctx.emit('<%event%>-restore', req, res);
//if OK
Expand Down
12 changes: 12 additions & 0 deletions stackpress/src/admin/transform/pages/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ export default function generate(directory: Directory, model: Model) {
moduleSpecifier: 'stackpress/admin/types',
namedImports: [ 'AdminConfig' ]
});
//import type { CsrfPlugin } from '../../types.js';
source.addImportDeclaration({
isTypeOnly: true,
moduleSpecifier: '../../types.js',
namedImports: [ 'CsrfPlugin' ]
});

//------------------------------------------------------------------//
// Import Client
Expand Down Expand Up @@ -92,6 +98,10 @@ if (res.body || (res.code && res.code !== 200)) {
//let the response pass through
return;
}
//get csrf plugin
const csrf = ctx.plugin<CsrfPlugin>('csrf');
//generate token
csrf.generateToken(res, ctx);
//get the view, brandm lang and admin config
const view = ctx.config.path<ViewConfig>('view', {});
const brand = ctx.config.path<BrandConfig>('brand', {});
Expand Down Expand Up @@ -125,6 +135,8 @@ res.data.set('admin', {

//if form submitted
if (req.method === 'POST' || req.method === 'PUT') {
//validate csrf
if (!csrf.validateToken(req, res)) return;
//emit update with the fixed fields
await ctx.emit('<%event%>-update', req, res);
//if OK
Expand Down
4 changes: 4 additions & 0 deletions stackpress/src/admin/transform/views/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,12 @@ return (
CREATE_FORM_BODY:
`const { input, errors } = props;
const { _ } = useLanguage();
const { config } = useServer();
const tokenKey = config.path('csrf.name', 'csrf');
const token = config.path('csrf.token', '');
return (
<form method="post">
<input type="hidden" name={tokenKey} value={token} />
<%fields%>
<Button className="submit" type="submit">
<i className="icon fas fa-fw fa-save"></i>
Expand Down
4 changes: 4 additions & 0 deletions stackpress/src/admin/transform/views/detail/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,8 +335,12 @@ CREATE_FORM_PROPS:
CREATE_FORM_BODY:
`const { input, errors } = props;
const { _ } = useLanguage();
const { config } = useServer();
const tokenKey = config.path('csrf.name', 'csrf');
const token = config.path('csrf.token', '');
return (
<form method="post">
<input type="hidden" name={tokenKey} value={token} />
<%fields%>
<Button className="submit" type="submit">
<i className="icon fas fa-fw fa-save"></i>
Expand Down
8 changes: 7 additions & 1 deletion stackpress/src/admin/transform/views/remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,12 @@ REMOVE_FORM_BODY:
const { base, can, results } = props;
//hooks
const { _ } = useLanguage();
const { config } = useServer();
//variables
const tokenKey = config.path('csrf.name', 'csrf');
const token = config.path('csrf.token', '');
const paramsObj = { confirmed: true, [tokenKey]: token };
const params = new URLSearchParams(paramsObj).toString();
//render
return (
<div>
Expand All @@ -238,7 +244,7 @@ return (
<span>{_('Nevermind.')}</span>
</a>
)}
<a className="action remove" href="?confirmed=true">
<a className="action remove" href={'?' + params}>
<i className="icon fas fa-fw fa-trash"></i>
<span>{_('Confirmed')}</span>
</a>
Expand Down
8 changes: 7 additions & 1 deletion stackpress/src/admin/transform/views/restore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,12 @@ RESTORE_FORM_BODY:
const { base, can, results } = props;
//hooks
const { _ } = useLanguage();
const { config } = useServer();
//variables
const tokenKey = config.path('csrf.name', 'csrf');
const token = config.path('csrf.token', '');
const paramsObj = { confirmed: true, [tokenKey]: token };
const params = new URLSearchParams(paramsObj).toString();
//render
return (
<div>
Expand All @@ -236,7 +242,7 @@ return (
<span>{_('Nevermind.')}</span>
</a>
)}
<a className="action restore" href="?confirmed=true">
<a className="action restore" href={'?' + params}>
<i className="icon fas fa-fw fa-check-circle"></i>
<span>{_('Confirmed')}</span>
</a>
Expand Down
4 changes: 4 additions & 0 deletions stackpress/src/admin/transform/views/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,12 @@ UPDATE_FORM_PROPS:
UPDATE_FORM_BODY:
`const { input, errors } = props;
const { _ } = useLanguage();
const { config } = useServer();
const tokenKey = config.path('csrf.name', 'csrf');
const token = config.path('csrf.token', '');
return (
<form method="post">
<input type="hidden" name={tokenKey} value={token} />
<%fields%>
<Button className="submit" type="submit">
<i className="icon fas fa-fw fa-save"></i>
Expand Down
4 changes: 4 additions & 0 deletions stackpress/src/csrf/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export {
CsrfConfig,
CsrfPlugin
} from './types';
71 changes: 71 additions & 0 deletions stackpress/src/csrf/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//node
import crypto from 'node:crypto';
//stackpress
import Server from '@stackpress/ingest/Server';
import { type Request, type Response } from '@stackpress/ingest';
//local
import { CsrfConfig } from './types';
import Exception from '../Exception.js';

export default function plugin(ctx: Server) {
ctx.on('config', (_req, _res, ctx) => {
//if no csrf config, disable csrf
if (!ctx.config.get('csrf')) return;
//configure and register csrf
ctx.register('csrf', {
generateToken(res: Response, ctx: Server) {
const token = crypto.randomBytes(32).toString('hex');
//get csrf name from config
const csrf = ctx.config.path<CsrfConfig>('csrf', {});
//set new token in session
res.session.set(csrf.name || 'csrf', token);
//set token in the response data for server side rendering
res.data.set('csrf', {
name: csrf.name || 'csrf',
token: token
});
//return the token
return token;
},
validateToken(req: Request, res: Response) {
//get csrf name from config
const name = ctx.config.path<string>('csrf.name', 'csrf');
//extract token from session and request
const sessionToken = String(req.session.get(name));
const inputToken = String(req.data(name));

//convert tokens to buffers for timingSafeEqual
const sessionBuffer = Buffer.from(sessionToken, 'utf-8');
const inputBuffer = Buffer.from(inputToken, 'utf-8');

const errorMessage = `This page may have been requested from an external source.
We corrected the issue. Please try again.`;

const exception = Exception
.for(errorMessage);

if (sessionBuffer.length !== inputBuffer.length) {
res.setError(
exception.toResponse(),
{},
[],
419,
'Page Expired'
);
return false;
}
if (!crypto.timingSafeEqual(sessionBuffer, inputBuffer)) {
res.setError(
exception.toResponse(),
{},
[],
419,
'Page Expired'
);
return false;
}
return true;
}
});
});
}
12 changes: 12 additions & 0 deletions stackpress/src/csrf/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type Response from '@stackpress/ingest/Response';
import type Request from '@stackpress/ingest/Request';
import type Server from '@stackpress/ingest/Server';

export type CsrfConfig = {
name?: string | undefined
};

export type CsrfPlugin = {
generateToken(res: Response, ctx: Server): string,
validateToken(req: Request, res: Response): boolean
};
3 changes: 3 additions & 0 deletions stackpress/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,9 @@ export type {
BuildConfig,
ProductionConfig,
ReactusConfig,
//stackpress/csrf
CsrfConfig,
CsrfPlugin,
//others
Scalar,
ExtendsType,
Expand Down
2 changes: 2 additions & 0 deletions stackpress/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import admin from './admin/plugin.js';
import language from './language/plugin.js';
import session from './session/plugin.js';
import api from './api/plugin.js';
import csrf from './csrf/plugin.js';

export default async function plugin(server: Server) {
//load the plugins
Expand All @@ -20,4 +21,5 @@ export default async function plugin(server: Server) {
language(server);
session(server);
api(server);
csrf(server);
};
Loading