Skip to content

Commit

Permalink
Replacing the CSRF package(CSURF) with Double CSRF package (#420)
Browse files Browse the repository at this point in the history
  • Loading branch information
pushyamig committed Mar 28, 2024
1 parent f3ef65e commit e30cb1c
Show file tree
Hide file tree
Showing 31 changed files with 178 additions and 249 deletions.
9 changes: 5 additions & 4 deletions ccm_web/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function App (): JSX.Element {

const location = useLocation()

const [globals, isAuthenticated, isLoading, globalsError, csrfTokenCookieError] = useGlobals()
const [globals, csrfToken, isAuthenticated, isLoading, globalsError, csrfTokenCookieError] = useGlobals()

const [course, setCourse] = useState<undefined|CanvasCourseBase>(undefined)
const [doLoadCourse, isCourseLoading, getCourseError] = usePromise<CanvasCourseBase|undefined, typeof getCourse>(
Expand All @@ -42,7 +42,7 @@ function App (): JSX.Element {

if (globalsError !== undefined) console.error(globalsError)
if (csrfTokenCookieError !== undefined) console.error(csrfTokenCookieError)
if (globals === undefined || !isAuthenticated) {
if (globals === undefined || !isAuthenticated || csrfToken === undefined) {
redirect('/access-denied')
return (loading)
}
Expand Down Expand Up @@ -71,16 +71,17 @@ function App (): JSX.Element {
: undefined

return (
<Layout {...{ features, pathnames }} devMode={globals?.environment === 'development'}>
<Layout {...{ features, pathnames }} devMode={globals?.environment === 'development'} csrfToken={csrfToken}>
<Switch>
<Route exact={true} path='/'>
<Home globals={globals} course={course} setCourse={setCourse} getCourseError={getCourseError} />
<Home globals={globals} csrfToken={csrfToken} course={course} setCourse={setCourse} getCourseError={getCourseError} />
</Route>
{features.map(feature => {
return (
<Route key={feature.data.id} path={feature.route}>
<feature.component
globals={globals}
csrfToken={csrfToken}
course={course}
title={feature.data.title}
helpURLEnding={feature.data.helpURLEnding}
Expand Down
56 changes: 25 additions & 31 deletions ccm_web/client/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
import Cookies from 'js-cookie'
import {
CanvasCourseBase, CanvasCourseSection, CanvasCourseSectionBase, CanvasEnrollment,
CanvasUserCondensed, CourseWithSections
} from './models/canvas'
import { ExternalUserSuccess } from './models/externalUser'
import { Globals } from './models/models'
import { Globals, CsrfToken } from './models/models'
import handleErrors, { CanvasError } from './utils/handleErrors'

const jsonMimeType = 'application/json'

export const getCSRFToken = (): string | undefined => Cookies.get('CSRF-Token')

const initCSRFRequest = (headers: Array<[string, string]>): RequestInit => {
const csrfToken = getCSRFToken()
if (csrfToken !== undefined) headers.push(['CSRF-Token', csrfToken])
const addStateChangeCallHeaders = (csrfToken: string): RequestInit => {
const headers: Array<[string, string]> = [['Content-Type', jsonMimeType], ['Accept', jsonMimeType], ['x-csrf-token', csrfToken]]
const request: RequestInit = { headers }
return request
}
Expand All @@ -24,26 +20,23 @@ const getGet = (): RequestInit => {
return request
}

const getPost = (body: string): RequestInit => {
const headers: Array<[string, string]> = [['Content-Type', jsonMimeType], ['Accept', jsonMimeType]]
const request = initCSRFRequest(headers)
const getPost = (body: string, csrfToken: string): RequestInit => {
const request = addStateChangeCallHeaders(csrfToken)
request.method = 'POST'
request.body = body
return request
}

const getDelete = (body: string): RequestInit => {
const headers: Array<[string, string]> = [['Content-Type', jsonMimeType], ['Accept', jsonMimeType]]
const request = initCSRFRequest(headers)
const getDelete = (body: string, csrfToken: string): RequestInit => {
const request = addStateChangeCallHeaders(csrfToken)
request.method = 'DELETE'
request.body = body
return request
}

// This currently assumes all put requests have a JSON payload and receive a JSON response.
const getPut = (body: string): RequestInit => {
const headers: Array<[string, string]> = [['Content-Type', jsonMimeType], ['Accept', jsonMimeType]]
const request = initCSRFRequest(headers)
const getPut = (body: string, csrfToken: string): RequestInit => {
const request = addStateChangeCallHeaders(csrfToken)
request.method = 'PUT'
request.body = body
return request
Expand All @@ -56,8 +49,8 @@ export const getCourse = async (courseId: number): Promise<CanvasCourseBase> =>
return await resp.json()
}

export const setCourseName = async (courseId: number, newName: string): Promise<CanvasCourseBase> => {
const request = getPut(JSON.stringify({ newName: newName }))
export const setCourseName = async (courseId: number, newName: string, csrfToken: string): Promise<CanvasCourseBase> => {
const request = getPut(JSON.stringify({ newName: newName }), csrfToken)
const resp = await fetch(`/api/course/${courseId}/name`, request)
await handleErrors(resp)
return await resp.json()
Expand All @@ -77,9 +70,9 @@ export const getCourseSections = async (courseId: number): Promise<CanvasCourseS
return await resp.json()
}

export const addCourseSections = async (courseId: number, sectionNames: string[]): Promise<CanvasCourseSection[]> => {
export const addCourseSections = async (courseId: number, sectionNames: string[], csrfToken: string): Promise<CanvasCourseSection[]> => {
const body = JSON.stringify({ sections: sectionNames })
const request = getPost(body)
const request = getPost(body, csrfToken)
const resp = await fetch('/api/course/' + courseId.toString() + '/sections', request)
await handleErrors(resp)
return await resp.json()
Expand All @@ -102,27 +95,28 @@ export const getStudentsEnrolledInSection = async (sectionId: number): Promise<s
}

export const addSectionEnrollments = async (
sectionId: number, enrollments: AddSectionEnrollment[]
sectionId: number, enrollments: AddSectionEnrollment[], csrfToken: string
): Promise<CanvasEnrollment[]> => {
const body = JSON.stringify({ users: enrollments })
const request = getPost(body)
const request = getPost(body, csrfToken)
const resp = await fetch(`/api/sections/${sectionId}/enroll`, request)
await handleErrors(resp)
return await resp.json()
}

export const addEnrollmentsToSections = async (enrollments: AddEnrollmentWithSectionId[]): Promise<CanvasEnrollment[]> => {
export const addEnrollmentsToSections = async (enrollments: AddEnrollmentWithSectionId[], csrfToken: string): Promise<CanvasEnrollment[]> => {
const body = JSON.stringify({ enrollments })
const request = getPost(body)
const request = getPost(body, csrfToken)
const resp = await fetch('/api/sections/enroll', request)
await handleErrors(resp)
return await resp.json()
}

export const setCSRFTokenCookie = async (): Promise<void> => {
export const getCSRFTokenResponse = async (): Promise<CsrfToken> => {
const request = getGet()
const resp = await fetch('/auth/csrfToken', request)
await handleErrors(resp)
return await resp.json()
}

export const getTeacherSections = async (termId: number): Promise<CourseWithSections[]> => {
Expand All @@ -140,17 +134,17 @@ export const searchSections = async (termId: number, searchType: 'uniqname' | 'c
return await resp.json()
}

export const mergeSections = async (courseId: number, sectionsToMerge: CanvasCourseSection[]): Promise<CanvasCourseSectionBase[]> => {
export const mergeSections = async (courseId: number, sectionsToMerge: CanvasCourseSection[], csrfToken: string): Promise<CanvasCourseSectionBase[]> => {
const body = JSON.stringify({ sectionIds: sectionsToMerge.map(section => { return section.id }) })
const request = getPost(body)
const request = getPost(body, csrfToken)
const resp = await fetch(`/api/course/${courseId}/sections/merge`, request)
await handleErrors(resp)
return await resp.json()
}

export const unmergeSections = async (sectionsToUnmerge: CanvasCourseSection[]): Promise<CanvasCourseSectionBase[]> => {
export const unmergeSections = async (sectionsToUnmerge: CanvasCourseSection[], csrfToken: string): Promise<CanvasCourseSectionBase[]> => {
const body = JSON.stringify({ sectionIds: sectionsToUnmerge.map(section => { return section.id }) })
const request = getDelete(body)
const request = getDelete(body, csrfToken)
const resp = await fetch('/api/sections/unmerge', request)
await handleErrors(resp)
return await resp.json()
Expand Down Expand Up @@ -181,9 +175,9 @@ interface ExternalUser {
givenName: string
}

export const createExternalUsers = async (newUsers: ExternalUser[]): Promise<ExternalUserSuccess[]> => {
export const createExternalUsers = async (newUsers: ExternalUser[], csrfToken: string): Promise<ExternalUserSuccess[]> => {
const body = JSON.stringify({ users: newUsers })
const request = getPost(body)
const request = getPost(body, csrfToken)
const resp = await fetch('/api/admin/createExternalUsers', request)
await handleErrors(resp)
return await resp.json()
Expand Down
2 changes: 1 addition & 1 deletion ccm_web/client/src/components/CourseSectionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ function CourseSectionList (props: CourseSectionListProps): JSX.Element {

const [sectionsToUnmerge, setSectionsToUnmerge] = useState<CanvasCourseSection[]>([])
const [doUnmerge, isUnmerging, unmergeError] = usePromise(
async () => await unmergeSections(sectionsToUnmerge),
async () => await unmergeSections(sectionsToUnmerge, props.csrfToken.token),
(unmergedSections: CanvasCourseSectionBase[]) => {
setSections(sections.filter(section => { return !unmergedSections.map(s => { return s.id }).includes(section.id) }))
setSectionsToUnmerge([])
Expand Down
4 changes: 3 additions & 1 deletion ccm_web/client/src/components/CreateSectionWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import APIErrorMessage from './APIErrorMessage'
import { addCourseSections } from '../api'
import { CanvasCourseBase, CanvasCourseSection } from '../models/canvas'
import { CanvasCoursesSectionNameValidator, ICanvasSectionNameInvalidError } from '../utils/canvasSectionNameValidator'
import { CsrfToken } from '../models/models'

const PREFIX = 'CreateSectionWidget'

Expand Down Expand Up @@ -39,6 +40,7 @@ const Root = styled('div')((

export interface CreateSectionWidgetProps {
course: CanvasCourseBase
csrfToken: CsrfToken
onSectionCreated: (newSection: CanvasCourseSection) => void
}

Expand Down Expand Up @@ -66,7 +68,7 @@ function CreateSectionWidget (props: CreateSectionWidgetProps): JSX.Element {
setIsCreating(true)
nameValidator.validateSectionName(newSectionName).then(errors => {
if (errors.length === 0) {
addCourseSections(props.course.id, [newSectionName])
addCourseSections(props.course.id, [newSectionName], props.csrfToken.token)
.then(newSections => {
props.onSectionCreated(newSections[0])
setNewSectionName('')
Expand Down
3 changes: 3 additions & 0 deletions ccm_web/client/src/components/CreateSelectSectionWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import HelpOutline from '@mui/icons-material/HelpOutline'
import CreateSectionWidget from './CreateSectionWidget'
import SectionSelectorWidget from './SectionSelectorWidget'
import { CanvasCourseBase, CanvasCourseSection, CanvasCourseSectionWithCourseName } from '../models/canvas'
import { CsrfToken } from '../models/models'

const PREFIX = 'CreateSelectSectionWidget'

Expand Down Expand Up @@ -68,6 +69,7 @@ interface CreateSelectSectionWidgetBaseProps {
sections: CanvasCourseSectionWithCourseName[]
selectedSection?: CanvasCourseSectionWithCourseName
setSelectedSection: (section: CanvasCourseSectionWithCourseName) => void
csrfToken: CsrfToken
}

type CreateSelectSectionWidgetProps = CreateSelectSectionWidgetBaseProps & CreateSelectSectionWidgetCreateProps
Expand Down Expand Up @@ -99,6 +101,7 @@ export default function CreateSelectSectionWidget (props: CreateSelectSectionWid
search={[]}
multiSelect={false}
sections={props.sections}
csrfToken={props.csrfToken}
selectedSections={props.selectedSection !== undefined ? [props.selectedSection] : []}
selectionUpdated={(sections) => props.setSelectedSection(sections[0])}
canUnmerge={false}
Expand Down
39 changes: 21 additions & 18 deletions ccm_web/client/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import BuildIcon from '@mui/icons-material/Build'

import Breadcrumbs, { BreadcrumbsProps } from './Breadcrumbs'
import ResponsiveHelper from './ResponsiveHelper'
import { getCSRFToken } from '../api'
import { CsrfToken } from '../models/models'

const PREFIX = 'Layout'

Expand Down Expand Up @@ -43,26 +43,29 @@ const StyledGrid = styled(Grid)((
interface LayoutProps extends BreadcrumbsProps {
devMode?: boolean
children: React.ReactNode
csrfToken?: CsrfToken
}

export default function Layout (props: LayoutProps): JSX.Element {
const devBlock = props.devMode === true && (
<>
<div className={`${classes.swaggerLink} ${classes.spacing}`}>
<Paper variant='outlined' className={classes.devModePaper}>
<Typography component='span' variant='subtitle1'>
<BuildIcon fontSize='small' /> Development Mode:&nbsp;
</Typography>
<Typography component='span'>
<Link href={`/swagger?csrfToken=${String(getCSRFToken())}`} target='_blank'>Swagger UI</Link>
</Typography>
</Paper>
</div>
<div style={{ position: 'fixed', right: '25px', top: '25px', zIndex: 999 }}>
<ResponsiveHelper />
</div>
</>
)
const devBlock = props.devMode === true && props.csrfToken ?
(
<>
<div className={`${classes.swaggerLink} ${classes.spacing}`}>
<Paper variant='outlined' className={classes.devModePaper}>
<Typography component='span' variant='subtitle1'>
<BuildIcon fontSize='small' /> Development Mode:&nbsp;
</Typography>
<Typography component='span'>
<Link href={`/swagger?csrfToken=${String(props.csrfToken.token)}`} target='_blank'>Swagger UI</Link>
</Typography>
</Paper>
</div>
<div style={{ position: 'fixed', right: '25px', top: '25px', zIndex: 999 }}>
<ResponsiveHelper />
</div>
</>
) :
null

return (
<StyledGrid container className={classes.root}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
REQUIRED_ENROLLMENT_WITH_SECTION_ID_HEADERS, SECTION_ID_TEXT, USER_ID_TEXT, USER_ROLE_TEXT
} from '../models/enrollment'
import { AddUMUsersLeafProps } from '../models/FeatureUIData'
import { InvalidationType } from '../models/models'
import { CsrfToken, InvalidationType } from '../models/models'
import CSVSchemaValidator, { SchemaInvalidation } from '../utils/CSVSchemaValidator'
import {
EnrollmentInvalidation, LoginIDRowsValidator, RoleRowsValidator, SectionIdRowsValidator
Expand Down Expand Up @@ -98,7 +98,9 @@ enum CSVWorkflowState {
Confirmation
}

interface MultipleSectionEnrollmentWorkflowProps extends AddUMUsersLeafProps {}
interface MultipleSectionEnrollmentWorkflowProps extends AddUMUsersLeafProps {
csrfToken: CsrfToken
}

export default function MultipleSectionEnrollmentWorkflow (props: MultipleSectionEnrollmentWorkflowProps): JSX.Element {
const parser = new FileParserWrapper()
Expand All @@ -114,7 +116,8 @@ export default function MultipleSectionEnrollmentWorkflow (props: MultipleSectio
const [doAddEnrollments, isAddEnrollmentsLoading, addEnrollmentsError, clearAddEnrollmentsError] = usePromise(
async (enrollments: AddEnrollmentWithSectionId[]) => {
await api.addEnrollmentsToSections(
enrollments.map(e => ({ loginId: e.loginId, role: e.role, sectionId: e.sectionId }))
enrollments.map(e => ({ loginId: e.loginId, role: e.role, sectionId: e.sectionId })),
props.csrfToken.token
)
},
() => setWorkflowState(CSVWorkflowState.Confirmation)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { AddNewExternalUserEnrollment, RowNumberedAddNewExternalUserEnrollment }
import { ExternalUserSuccess, isExternalUserSuccess } from '../models/externalUser'
import { createSectionRoles } from '../models/feature'
import { AddNonUMUsersLeafProps, isAuthorizedForRoles } from '../models/FeatureUIData'
import { CSVWorkflowStep, InvalidationType, RoleEnum } from '../models/models'
import { CSVWorkflowStep, CsrfToken, InvalidationType, RoleEnum } from '../models/models'
import CSVSchemaValidator, { SchemaInvalidation } from '../utils/CSVSchemaValidator'
import {
DuplicateEmailRowsValidator, EmailRowsValidator, EnrollmentInvalidation, FirstNameRowsValidator,
Expand Down Expand Up @@ -91,6 +91,7 @@ export const isExternalEnrollmentRecord = (record: CSVRecord): record is Externa

interface MultipleUserEnrollmentWorkflowProps extends AddNonUMUsersLeafProps {
course: CanvasCourseBase
csrfToken: CsrfToken
onSectionCreated: (newSection: CanvasCourseSection) => void
userCourseRoles: RoleEnum[]
}
Expand All @@ -116,7 +117,8 @@ export default function MultipleUserEnrollmentWorkflow (props: MultipleUserEnrol
const errors: ErrorDescription[] = []
try {
successes = await api.createExternalUsers(
enrollments.map(e => ({ email: e.email, givenName: e.firstName, surname: e.lastName }))
enrollments.map(e => ({ email: e.email, givenName: e.firstName, surname: e.lastName })),
props.csrfToken.token
)
} catch (error: unknown) {
if (error instanceof ExternalUserProcessError) {
Expand All @@ -132,7 +134,7 @@ export default function MultipleUserEnrollmentWorkflow (props: MultipleUserEnrol
if (enrollmentsToAdd.length > 0) {
try {
await api.addSectionEnrollments(
sectionId, enrollmentsToAdd.map(e => ({ loginId: e.email, role: e.role }))
sectionId, enrollmentsToAdd.map(e => ({ loginId: e.email, role: e.role })), props.csrfToken.token
)
} catch (error: unknown) {
if (error instanceof CanvasError) {
Expand Down Expand Up @@ -205,6 +207,7 @@ export default function MultipleUserEnrollmentWorkflow (props: MultipleUserEnrol
selectedSection={selectedSection}
setSelectedSection={setSelectedSection}
{...createProps}
csrfToken={props.csrfToken}
/>
<Backdrop className={classes.backdrop} open={props.isGetSectionsLoading}>
<Grid container>
Expand Down
4 changes: 3 additions & 1 deletion ccm_web/client/src/components/SectionSelectorWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { unmergeSections } from '../api'
import usePromise from '../hooks/usePromise'
import { CanvasCourseSectionBase, CanvasCourseSectionWithCourseName, ICanvasCourseSectionSort } from '../models/canvas'
import { ISectionSearcher } from '../utils/SectionSearcher'
import { CsrfToken } from '../models/models'

const PREFIX = 'SectionSelectorWidget'

Expand Down Expand Up @@ -204,6 +205,7 @@ interface ISectionSelectorWidgetProps {
canUnmerge: boolean
sectionsRemoved?: (sections: CanvasCourseSectionBase[]) => void
highlightUnlocked?: boolean
csrfToken: CsrfToken
}

function SectionSelectorWidget (props: ISectionSelectorWidgetProps): JSX.Element {
Expand All @@ -226,7 +228,7 @@ function SectionSelectorWidget (props: ISectionSelectorWidgetProps): JSX.Element
const [searchFieldLabel, setSearchFieldLabel] = useState<string | undefined>(props.search.length > 0 ? (props.search)[0].helperText : undefined)

const [doUnmerge, isUnmerging, unmergeError] = usePromise(
async (sections: CanvasCourseSectionWithCourseName[]) => await unmergeSections(sections),
async (sections: CanvasCourseSectionWithCourseName[]) => await unmergeSections(sections, props.csrfToken.token),
(unmergedSections: CanvasCourseSectionBase[]) => {
const unmergedSectionIds = unmergedSections.map(s => s.id)
setInternalSections(internalSections.filter(section => !unmergedSectionIds.includes(section.id)))
Expand Down

0 comments on commit e30cb1c

Please sign in to comment.