Skip to content

Commit

Permalink
fix(server): add CSP, cors (#2532)
Browse files Browse the repository at this point in the history
## Describe your changes

Fixes
https://linear.app/nango/issue/NAN-1339/[pen-test]-fix-security-misconfiguration-exposed-in-pen-test
Fixes
https://linear.app/nango/issue/NAN-1340/[pen-test]-content-security-policy-csp-header-not-implemented
Fixes
https://linear.app/nango/issue/NAN-1454/remediate-medium-severity-vulnerabilities-from-pen-test

- Add helmet.js to fix all notice related to SOC2 
  - CSP
  - No Sniff
  - No embed in iframe
  - HSTS
  
- Setup stricter cors
Allows all origin for public api and expose headers, restrict private
api
  • Loading branch information
bodinsamuel authored Jul 23, 2024
1 parent 7181c1c commit 2c009d0
Show file tree
Hide file tree
Showing 7 changed files with 139 additions and 50 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ WORKER_PORT=3004
# - Configure server full URL (current value is the default for running Nango locally).
#
NANGO_SERVER_URL=http://localhost:3003
CSP_REPORT_ONLY=false
#
#
# - Configure server websockets path (current value is the default for running Nango locally).
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ services:
- NANGO_DB_POOL_MAX=${NANGO_DB_POOL_MAX}
- RECORDS_DATABASE_URL=${RECORDS_DATABASE_URL:-postgresql://nango:nango@nango-db:5432/nango}
- SERVER_PORT=${SERVER_PORT}
- CSP_REPORT_ONLY=true
- NANGO_SERVER_URL=${NANGO_SERVER_URL:-http://localhost:3003}
- NANGO_DASHBOARD_USERNAME=${NANGO_DASHBOARD_USERNAME}
- NANGO_DASHBOARD_PASSWORD=${NANGO_DASHBOARD_PASSWORD}
Expand Down
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

48 changes: 48 additions & 0 deletions packages/server/lib/middleware/security.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { basePublicUrl, baseUrl } from '@nangohq/utils';
import type { RequestHandler } from 'express';
import helmet from 'helmet';

export function securityMiddlewares(): RequestHandler[] {
const hostPublic = basePublicUrl;
const hostApi = baseUrl;
const reportOnly = process.env['CSP_REPORT_ONLY'];

return [
helmet.xssFilter(),
helmet.noSniff(),
helmet.ieNoOpen(),
helmet.frameguard({ action: 'sameorigin' }),
helmet.dnsPrefetchControl(),
helmet.hsts({
maxAge: 5184000
}),
// == "Content-Security-Policy"
helmet.contentSecurityPolicy({
reportOnly: reportOnly !== 'false',
directives: {
defaultSrc: ["'self'", hostPublic, hostApi],
childSrc: "'self'",
connectSrc: ["'self'", 'https://*.google-analytics.com', 'https://*.sentry.io', hostPublic, hostApi, 'https://*.posthog.com'],
fontSrc: ["'self'", 'https://*.googleapis.com', 'https://*.gstatic.com'],
frameSrc: ["'self'", 'https://accounts.google.com'],
imgSrc: ["'self'", 'data:', hostPublic, hostApi, 'https://*.google-analytics.com', 'https://*.googleapis.com', 'https://*.posthog.com'],
manifestSrc: "'self'",
mediaSrc: "'self'",
objectSrc: "'self'",
scriptSrc: [
"'self'",
"'unsafe-eval'",
"'unsafe-inline'",
hostPublic,
hostApi,
'https://*.google-analytics.com',
'https://*.googleapis.com',
'https://apis.google.com',
'https://*.posthog.com'
],
styleSrc: ['blob:', "'self'", "'unsafe-inline'", 'https://*.googleapis.com', hostPublic, hostApi],
workerSrc: ['blob:', "'self'", hostPublic, hostApi, 'https://*.googleapis.com', 'https://*.posthog.com']
}
})
];
}
123 changes: 75 additions & 48 deletions packages/server/lib/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import passport from 'passport';
import environmentController from './controllers/environment.controller.js';
import accountController from './controllers/account.controller.js';
import type { Response, Request } from 'express';
import { isCloud, isEnterprise, flagHasAuth, isBasicAuthEnabled, isTest, flagHasManagedAuth } from '@nangohq/utils';
import { isCloud, isEnterprise, isBasicAuthEnabled, isTest, isLocal, basePublicUrl, baseUrl, flagHasAuth, flagHasManagedAuth } from '@nangohq/utils';
import { errorManager } from '@nangohq/shared';
import tracer from 'dd-trace';
import { getConnection as getConnectionWeb } from './controllers/v1/connection/get.js';
Expand Down Expand Up @@ -66,12 +66,15 @@ import { patchUser } from './controllers/v1/user/patchUser.js';
import { getInvite } from './controllers/v1/invite/getInvite.js';
import { declineInvite } from './controllers/v1/invite/declineInvite.js';
import { acceptInvite } from './controllers/v1/invite/acceptInvite.js';
import { securityMiddlewares } from './middleware/security.js';
import { getMeta } from './controllers/v1/meta/getMeta.js';
import { postManagedSignup } from './controllers/v1/account/managed/postSignup.js';
import { getManagedCallback } from './controllers/v1/account/managed/getCallback.js';

export const router = express.Router();

router.use(...securityMiddlewares());

const apiAuth = [authMiddleware.secretKeyAuth.bind(authMiddleware), rateLimiterMiddleware];
const adminAuth = [authMiddleware.secretKeyAuth.bind(authMiddleware), authMiddleware.adminKeyAuth.bind(authMiddleware), rateLimiterMiddleware];
const apiPublicAuth = [authMiddleware.publicKeyAuth.bind(authMiddleware), authCheck, rateLimiterMiddleware];
Expand All @@ -95,71 +98,95 @@ router.use(
})
);
router.use(bodyParser.raw({ type: 'text/xml' }));
router.use(cors());
router.use(express.urlencoded({ extended: true }));

const upload = multer({ storage: multer.memoryStorage() });

// -------
// API routes (no/public auth).
router.get('/health', (_, res) => {
res.status(200).send({ result: 'ok' });
});

router.route('/oauth/callback').get(oauthController.oauthCallback.bind(oauthController));
router.route('/webhook/:environmentUuid/:providerConfigKey').post(webhookController.receive.bind(proxyController));
router.route('/app-auth/connect').get(appAuthController.connect.bind(appAuthController));
router.route('/oauth/connect/:providerConfigKey').get(apiPublicAuth, oauthController.oauthRequest.bind(oauthController));
router.route('/oauth2/auth/:providerConfigKey').post(apiPublicAuth, oauthController.oauth2RequestCC.bind(oauthController));
router.route('/api-auth/api-key/:providerConfigKey').post(apiPublicAuth, apiAuthController.apiKey.bind(apiAuthController));
router.route('/api-auth/basic/:providerConfigKey').post(apiPublicAuth, apiAuthController.basic.bind(apiAuthController));
router.route('/app-store-auth/:providerConfigKey').post(apiPublicAuth, appStoreAuthController.auth.bind(appStoreAuthController));
router.route('/auth/tba/:providerConfigKey').post(apiPublicAuth, tbaAuthorization);
router.route('/unauth/:providerConfigKey').post(apiPublicAuth, unAuthController.create.bind(unAuthController));
// -------
// Public API routes
const publicAPI = express.Router();
const publicAPICorsHandler = cors({
maxAge: 600,
exposedHeaders: 'Authorization, Etag, Content-Type, Content-Length, X-Nango-Signature, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset',
allowedHeaders: 'Nango-Activity-Log-Id, Nango-Is-Dry-Run, Nango-Is-Sync, Provider-Config-Key, Connection-Id',
origin: '*'
});
publicAPI.use(publicAPICorsHandler);
publicAPI.options('*', publicAPICorsHandler); // Pre-flight

publicAPI.route('/oauth/callback').get(oauthController.oauthCallback.bind(oauthController));
publicAPI.route('/webhook/:environmentUuid/:providerConfigKey').post(webhookController.receive.bind(proxyController));
publicAPI.route('/app-auth/connect').get(appAuthController.connect.bind(appAuthController));
publicAPI.route('/oauth/connect/:providerConfigKey').get(apiPublicAuth, oauthController.oauthRequest.bind(oauthController));
publicAPI.route('/oauth2/auth/:providerConfigKey').post(apiPublicAuth, oauthController.oauth2RequestCC.bind(oauthController));
publicAPI.route('/api-auth/api-key/:providerConfigKey').post(apiPublicAuth, apiAuthController.apiKey.bind(apiAuthController));
publicAPI.route('/api-auth/basic/:providerConfigKey').post(apiPublicAuth, apiAuthController.basic.bind(apiAuthController));
publicAPI.route('/app-store-auth/:providerConfigKey').post(apiPublicAuth, appStoreAuthController.auth.bind(appStoreAuthController));
publicAPI.route('/auth/tba/:providerConfigKey').post(apiPublicAuth, tbaAuthorization);
publicAPI.route('/unauth/:providerConfigKey').post(apiPublicAuth, unAuthController.create.bind(unAuthController));

// API Admin routes
router.route('/admin/flow/deploy/pre-built').post(adminAuth, flowController.adminDeployPrivateFlow.bind(flowController));
router.route('/admin/customer').patch(adminAuth, accountController.editCustomer.bind(accountController));
publicAPI.route('/admin/flow/deploy/pre-built').post(adminAuth, flowController.adminDeployPrivateFlow.bind(flowController));
publicAPI.route('/admin/customer').patch(adminAuth, accountController.editCustomer.bind(accountController));

// API routes (API key auth).
router.route('/provider').get(apiAuth, providerController.listProviders.bind(providerController));
router.route('/provider/:provider').get(apiAuth, providerController.getProvider.bind(providerController));
router.route('/config').get(apiAuth, configController.listProviderConfigs.bind(configController));
router.route('/config/:providerConfigKey').get(apiAuth, configController.getProviderConfig.bind(configController));
router.route('/config').post(apiAuth, configController.createProviderConfig.bind(configController));
router.route('/config').put(apiAuth, configController.editProviderConfig.bind(configController));
router.route('/config/:providerConfigKey').delete(apiAuth, configController.deleteProviderConfig.bind(configController));
router.route('/connection/:connectionId').get(apiAuth, connectionController.getConnectionCreds.bind(connectionController));
router.route('/connection').get(apiAuth, connectionController.listConnections.bind(connectionController));
router.route('/connection/:connectionId').delete(apiAuth, connectionController.deleteConnection.bind(connectionController));
router.route('/connection/:connectionId/metadata').post(apiAuth, connectionController.setMetadataLegacy.bind(connectionController));
router.route('/connection/:connectionId/metadata').patch(apiAuth, connectionController.updateMetadataLegacy.bind(connectionController));
router.route('/connection/metadata').post(apiAuth, setMetadata);
router.route('/connection/metadata').patch(apiAuth, updateMetadata);
router.route('/connection').post(apiAuth, connectionController.createConnection.bind(connectionController));
router.route('/environment-variables').get(apiAuth, environmentController.getEnvironmentVariables.bind(connectionController));
router.route('/sync/deploy').post(apiAuth, postDeploy);
router.route('/sync/deploy/confirmation').post(apiAuth, postDeployConfirmation);
router.route('/sync/update-connection-frequency').put(apiAuth, syncController.updateFrequencyForConnection.bind(syncController));
router.route('/records').get(apiAuth, syncController.getAllRecords.bind(syncController));
router.route('/sync/trigger').post(apiAuth, syncController.trigger.bind(syncController));
router.route('/sync/pause').post(apiAuth, syncController.pause.bind(syncController));
router.route('/sync/start').post(apiAuth, syncController.start.bind(syncController));
router.route('/sync/provider').get(apiAuth, syncController.getSyncProvider.bind(syncController));
router.route('/sync/status').get(apiAuth, syncController.getSyncStatus.bind(syncController));
router.route('/sync/:syncId').delete(apiAuth, syncController.deleteSync.bind(syncController));
router.route('/flow/attributes').get(apiAuth, syncController.getFlowAttributes.bind(syncController));
router.route('/flow/configs').get(apiAuth, flowController.getFlowConfig.bind(flowController));
router.route('/scripts/config').get(apiAuth, flowController.getFlowConfig.bind(flowController));
router.route('/action/trigger').post(apiAuth, syncController.triggerAction.bind(syncController)); //TODO: to deprecate

router.route('/v1/*').all(apiAuth, syncController.actionOrModel.bind(syncController));

router.route('/proxy/*').all(apiAuth, upload.any(), proxyController.routeCall.bind(proxyController));
publicAPI.route('/provider').get(apiAuth, providerController.listProviders.bind(providerController));
publicAPI.route('/provider/:provider').get(apiAuth, providerController.getProvider.bind(providerController));
publicAPI.route('/config').get(apiAuth, configController.listProviderConfigs.bind(configController));
publicAPI.route('/config/:providerConfigKey').get(apiAuth, configController.getProviderConfig.bind(configController));
publicAPI.route('/config').post(apiAuth, configController.createProviderConfig.bind(configController));
publicAPI.route('/config').put(apiAuth, configController.editProviderConfig.bind(configController));
publicAPI.route('/config/:providerConfigKey').delete(apiAuth, configController.deleteProviderConfig.bind(configController));
publicAPI.route('/connection/:connectionId').get(apiAuth, connectionController.getConnectionCreds.bind(connectionController));
publicAPI.route('/connection').get(apiAuth, connectionController.listConnections.bind(connectionController));
publicAPI.route('/connection/:connectionId').delete(apiAuth, connectionController.deleteConnection.bind(connectionController));
publicAPI.route('/connection/:connectionId/metadata').post(apiAuth, connectionController.setMetadataLegacy.bind(connectionController));
publicAPI.route('/connection/:connectionId/metadata').patch(apiAuth, connectionController.updateMetadataLegacy.bind(connectionController));
publicAPI.route('/connection/metadata').post(apiAuth, setMetadata);
publicAPI.route('/connection/metadata').patch(apiAuth, updateMetadata);
publicAPI.route('/connection').post(apiAuth, connectionController.createConnection.bind(connectionController));
publicAPI.route('/environment-variables').get(apiAuth, environmentController.getEnvironmentVariables.bind(connectionController));
publicAPI.route('/sync/deploy').post(apiAuth, postDeploy);
publicAPI.route('/sync/deploy/confirmation').post(apiAuth, postDeployConfirmation);
publicAPI.route('/sync/update-connection-frequency').put(apiAuth, syncController.updateFrequencyForConnection.bind(syncController));
publicAPI.route('/records').get(apiAuth, syncController.getAllRecords.bind(syncController));
publicAPI.route('/sync/trigger').post(apiAuth, syncController.trigger.bind(syncController));
publicAPI.route('/sync/pause').post(apiAuth, syncController.pause.bind(syncController));
publicAPI.route('/sync/start').post(apiAuth, syncController.start.bind(syncController));
publicAPI.route('/sync/provider').get(apiAuth, syncController.getSyncProvider.bind(syncController));
publicAPI.route('/sync/status').get(apiAuth, syncController.getSyncStatus.bind(syncController));
publicAPI.route('/sync/:syncId').delete(apiAuth, syncController.deleteSync.bind(syncController));
publicAPI.route('/flow/attributes').get(apiAuth, syncController.getFlowAttributes.bind(syncController));
publicAPI.route('/flow/configs').get(apiAuth, flowController.getFlowConfig.bind(flowController));
publicAPI.route('/scripts/config').get(apiAuth, flowController.getFlowConfig.bind(flowController));
publicAPI.route('/action/trigger').post(apiAuth, syncController.triggerAction.bind(syncController)); //TODO: to deprecate

publicAPI.route('/v1/*').all(apiAuth, syncController.actionOrModel.bind(syncController));

publicAPI.route('/proxy/*').all(apiAuth, upload.any(), proxyController.routeCall.bind(proxyController));

router.use(publicAPI);

// -------
// Webapp routes (session auth).
const web = express.Router();
setupAuth(web);

const webCorsHandler = cors({
maxAge: 600,
exposedHeaders: 'Authorization, Etag, Content-Type, Content-Length, Set-Cookie',
origin: isLocal ? '*' : [basePublicUrl, baseUrl],
credentials: true
});
web.use(webCorsHandler);
web.options('*', webCorsHandler); // Pre-flight

// Webapp routes (no auth).
if (flagHasAuth) {
web.route('/api/v1/account/signup').post(rateLimiterMiddleware, signup);
Expand Down
2 changes: 2 additions & 0 deletions packages/server/lib/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const { NANGO_MIGRATE_AT_START = 'true' } = process.env;
const logger = getLogger('Server');

const app = express();
app.disable('x-powered-by');
app.set('trust proxy', 1);

// Log all requests
if (process.env['ENABLE_REQUEST_LOG'] !== 'false') {
Expand Down
5 changes: 3 additions & 2 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@
"@nangohq/records": "file:../records",
"@nangohq/shared": "file:../shared",
"@nangohq/types": "file:../types",
"@nangohq/webhooks": "file:../webhooks",
"@nangohq/utils": "file:../utils",
"@nangohq/webhooks": "file:../webhooks",
"@workos-inc/node": "^6.2.0",
"axios": "^1.3.4",
"body-parser": "1.20.2",
Expand All @@ -44,6 +44,7 @@
"express": "^4.19.2",
"express-session": "^1.17.3",
"form-data": "^4.0.0",
"helmet": "7.1.0",
"jsonwebtoken": "^9.0.2",
"lodash-es": "^4.17.21",
"mailgun.js": "^8.2.1",
Expand Down Expand Up @@ -79,8 +80,8 @@
"@types/ws": "^8.5.4",
"get-port": "7.1.0",
"nodemon": "^3.0.1",
"typescript": "5.3.3",
"type-fest": "4.14.0",
"typescript": "5.3.3",
"vitest": "1.6.0"
}
}

0 comments on commit 2c009d0

Please sign in to comment.