Skip to content

Commit

Permalink
CHAL-83 #done - Add csp and csrf protection (#56)
Browse files Browse the repository at this point in the history
* testing CSP

* added script that generates CSP header

* added vercel.live to allowed sources so vercel toolbar works in previews

* added CSRF protection
  • Loading branch information
dvorakjt authored Oct 16, 2024
1 parent b3d191f commit 8aac652
Show file tree
Hide file tree
Showing 19 changed files with 465 additions and 19 deletions.
4 changes: 3 additions & 1 deletion .github/workflows/deploy-and-test-staging-branch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ on:
branches:
- staging

env:
SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }}

jobs:
reset-db:
runs-on: ubuntu-latest
environment: staging
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }}
SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }}

steps:
- uses: actions/checkout@v4
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/deploy-production-branch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ on:
branches:
- production

env:
SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }}

jobs:
run-migrations:
runs-on: ubuntu-latest
environment: production
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
SUPABASE_DB_PASSWORD: ${{ secrets.SUPABASE_DB_PASSWORD }}
SUPABASE_PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }}

steps:
- uses: actions/checkout@v4
Expand Down
6 changes: 6 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createCSP } from './scripts/create-content-security-policy.mjs';

/** @type {import('next').NextConfig} */
const nextConfig = {
/*
Expand All @@ -17,6 +19,10 @@ const nextConfig = {
},
],
},
{
source: '/(.*)',
headers: [createCSP()],
},
];
},

Expand Down
139 changes: 139 additions & 0 deletions scripts/create-content-security-policy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
const KeywordValues = {
None: "'none'",
Self: "'self'",
UnsafeInline: "'unsafe-inline'",
UnsafeEval: "'unsafe-eval'",
};

const ExternalSources = {
RockTheVote: 'https://register.rockthevote.com/',
RockyAPIAssets: 'https://s3.amazonaws.com/rocky-assets/',
Cloudflare: 'https://challenges.cloudflare.com/',
VercelTools: 'https://vercel.live/',
};

/**
* Creates the Content Security Policy header depending on the environment.
*
* For more information about the CSP header, please see
* {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP}.
*/
export function createCSP() {
const directives = [
{
directive: 'default-src',
values: [KeywordValues.Self],
},
{
directive: 'script-src',
values: getAllowedScriptSources(),
},
{
/*
Allow Rock the Vote's Pledge to Vote form and the Cloudflare Turnstile
widget to be rendered in IFrames.
*/
directive: 'frame-src',
values: [ExternalSources.RockTheVote, ExternalSources.Cloudflare],
},
{
directive: 'connect-src',
values: [KeywordValues.Self, getSupabaseRealtimeSocketURL()],
},
{
directive: 'style-src',
values: [KeywordValues.Self, KeywordValues.UnsafeInline],
},
{
directive: 'img-src',
values: [KeywordValues.Self, 'blob:', 'data:'],
},
{
directive: 'font-src',
values: [KeywordValues.Self],
},
{
directive: 'object-src',
values: [KeywordValues.None],
},
{
directive: 'base-uri',
values: [KeywordValues.Self],
},
{
directive: 'form-action',
// will this allow the election reminders form to be submitted?
values: [KeywordValues.Self],
},
{
directive: 'frame-ancestors',
values: [KeywordValues.None],
},
{
directive: 'block-all-mixed-content',
},
{
directive: 'upgrade-insecure-requests',
},
];

return {
key: 'Content-Security-Policy',
value: joinDirectives(directives),
};
}

/**
* Returns an array of allowed script sources, which differs depending on
* whether the app is running via the dev server or the production server.
*/
function getAllowedScriptSources() {
const allowedScriptSources = [
KeywordValues.Self,
KeywordValues.UnsafeInline,
ExternalSources.RockTheVote,
ExternalSources.RockyAPIAssets,
ExternalSources.Cloudflare,
ExternalSources.VercelTools,
];

// 'unsafe-eval' is required by Next.js when running the dev server
if (process.env.NODE_ENV !== 'production') {
allowedScriptSources.push(KeywordValues.UnsafeEval);
}

return allowedScriptSources;
}

/**
* Gets the websocket URL for Supabase Realtime. If `process.env.APP_ENV` is
* 'production', expects process.env.SUPABASE_PROJECT_ID to be defined.
*
* For information on the format of this URL, please see
* {@link https://github.com/supabase/realtime?tab=readme-ov-file#websocket-url}.
*/
function getSupabaseRealtimeSocketURL() {
if (process.env.APP_ENV === 'production') {
if (!process.env.SUPABASE_PROJECT_ID) {
throw new Error(
'Could not read environment variable SUPABASE_PROJECT_ID. Are you sure it has been declared?',
);
}

return `wss://${process.env.SUPABASE_PROJECT_ID}.supabase.co/realtime/`;
} else {
return 'ws://127.0.0.1:54321/realtime/';
}
}

/**
* Takes in an array of directive objects and joins them into a single string
* separated by semi-colons.
*/
function joinDirectives(directives) {
return directives
.map(({ directive, values }) => {
return `${values && values.length ? [directive, ...values].join(' ') : directive};`;
})
.join('');
}
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,9 @@ exports[`Why8by8 renders why8by8 page unchanged 1`] = `
<q>
I joined the 8by8 partnerships team hoping to leverage my network and make a difference to the future of AAPI in America. Voter registration is a great starting point. My dream is to build a chain of modern and inclusive community centers for Asian Americans. With more civic engagement and political presence, the journey for modern Asian Community Centers can be easier and smoother!
</q>
<aside>
<aside
style="position: relative; z-index: 2;"
>
<span
class="quote_gap_2"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { mockDialogMethods } from '@/utils/test/mock-dialog-methods';
import { VoterRegistrationForm } from '@/app/register/voter-registration-form';
import { isErrorWithMessage } from '@/utils/shared/is-error-with-message';
import { calculateDaysRemaining } from '@/app/progress/calculate-days-remaining';
import { CSRF_HEADER } from '@/utils/csrf/constants';
import type { User } from '@/model/types/user';
import type { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime';
import type { Avatar } from '@/model/types/avatar';
Expand Down Expand Up @@ -150,6 +151,9 @@ describe('ClientSideUserContextProvider', () => {
expect(fetchSpy).toHaveBeenCalledWith('/api/signup-with-email', {
method: 'POST',
body: JSON.stringify(signUpParams),
headers: {
[CSRF_HEADER]: expect.any(String),
},
}),
);

Expand Down Expand Up @@ -314,6 +318,9 @@ describe('ClientSideUserContextProvider', () => {
expect(fetchSpy).toHaveBeenCalledWith('/api/send-otp-to-email', {
method: 'POST',
body: JSON.stringify(sendOTPToEmailParams),
headers: {
[CSRF_HEADER]: expect.any(String),
},
}),
);

Expand Down Expand Up @@ -465,6 +472,9 @@ describe('ClientSideUserContextProvider', () => {
expect(fetchSpy).toHaveBeenCalledWith('/api/resend-otp-to-email', {
method: 'POST',
body: JSON.stringify({ email }),
headers: {
[CSRF_HEADER]: expect.any(String),
},
}),
);

Expand Down Expand Up @@ -639,6 +649,9 @@ describe('ClientSideUserContextProvider', () => {
expect(fetchSpy).toHaveBeenCalledWith('/api/signin-with-otp', {
method: 'POST',
body: JSON.stringify({ email: expectedUser.email, otp }),
headers: {
[CSRF_HEADER]: expect.any(String),
},
}),
);

Expand Down Expand Up @@ -797,6 +810,9 @@ describe('ClientSideUserContextProvider', () => {
await waitFor(() =>
expect(fetchSpy).toHaveBeenCalledWith('/api/signout', {
method: 'DELETE',
headers: {
[CSRF_HEADER]: expect.any(String),
},
}),
);

Expand Down Expand Up @@ -1036,6 +1052,7 @@ describe('ClientSideUserContextProvider', () => {
await user.click(screen.getByText(/get reminders/i));
expect(fetchSpy).not.toHaveBeenCalledWith(
'/api/award-election-reminders-badge',
expect.anything(),
);
fetchSpy.mockRestore();
});
Expand Down Expand Up @@ -1392,7 +1409,10 @@ describe('ClientSideUserContextProvider', () => {
);

await user.click(screen.getByText('Take the challenge'));
expect(fetchSpy).not.toHaveBeenCalledWith('/api/take-the-challenge');
expect(fetchSpy).not.toHaveBeenCalledWith(
'/api/take-the-challenge',
expect.anything(),
);
fetchSpy.mockRestore();
});

Expand Down Expand Up @@ -1444,7 +1464,10 @@ describe('ClientSideUserContextProvider', () => {
);

await user.click(screen.getByText('Take the challenge'));
expect(fetchSpy).not.toHaveBeenCalledWith('/api/take-the-challenge');
expect(fetchSpy).not.toHaveBeenCalledWith(
'/api/take-the-challenge',
expect.anything(),
);
fetchSpy.mockRestore();
});

Expand Down Expand Up @@ -1712,7 +1735,10 @@ describe('ClientSideUserContextProvider', () => {
);

await user.click(screen.getByText(/register/i));
expect(fetchSpy).not.toHaveBeenCalledWith('/api/register-to-vote');
expect(fetchSpy).not.toHaveBeenCalledWith(
'/api/register-to-vote',
expect.anything(),
);
fetchSpy.mockRestore();
});

Expand Down Expand Up @@ -2033,9 +2059,10 @@ describe('ClientSideUserContextProvider', () => {
</AlertsContextProvider>,
);
await user.click(screen.getByText(/Share/i));
expect(fetchSpy).not.toHaveBeenCalledWith('/api/share-challenge', {
method: 'PUT',
});
expect(fetchSpy).not.toHaveBeenCalledWith(
'/api/share-challenge',
expect.anything(),
);
fetchSpy.mockRestore();
});

Expand Down Expand Up @@ -2227,9 +2254,10 @@ describe('ClientSideUserContextProvider', () => {
</AlertsContextProvider>,
);
await user.click(screen.getByText(/restart/i));
expect(fetchSpy).not.toHaveBeenCalledWith('/api/restart-challenge', {
method: 'PUT',
});
expect(fetchSpy).not.toHaveBeenCalledWith(
'/api/restart-challenge',
expect.anything(),
);
fetchSpy.mockRestore();
});
});
Loading

0 comments on commit 8aac652

Please sign in to comment.