Fetch CSRF token over XHR (browser-side) (#29337)

* Client side csrf token grab

* Update get-session.ts

* Update get-session.ts

* Update get-session.ts

* Remove test refs to meta tag

* Update get-session.ts

* Update get-session.ts

* Update get-session.ts

* Update get-session.ts

* Fix some type issues

* Simplify api

* Update components/lib/get-session.ts

Co-authored-by: Rachael Sewell <rachmari@github.com>

Co-authored-by: Rachael Sewell <rachmari@github.com>
This commit is contained in:
Kevin Heis 2022-07-27 09:18:07 -07:00 коммит произвёл GitHub
Родитель b19e5a6ac3
Коммит f79e1d2cb7
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
7 изменённых файлов: 67 добавлений и 58 удалений

Просмотреть файл

@ -1,8 +1,8 @@
/* eslint-disable camelcase */
import { v4 as uuidv4 } from 'uuid'
import Cookies from 'js-cookie'
import getCsrf from './get-csrf'
import parseUserAgent from './user-agent'
import { getSession, fetchSession } from './get-session'
const COOKIE_NAME = '_docs-events'
@ -83,8 +83,10 @@ function getMetaContent(name: string) {
}
export function sendEvent({ type, version = '1.0.0', ...props }: SendEventProps) {
const session = getSession()
const body = {
_csrf: getCsrf(),
_csrf: session?.csrfToken,
type,
@ -273,14 +275,16 @@ function initPrintEvent() {
}
export default function initializeEvents() {
initPageAndExitEvent() // must come first
initLinkEvent()
initClipboardEvent()
initCopyButtonEvent()
initPrintEvent()
// survey event in ./survey.js
// experiment event in ./experiment.js
// search and search_result event in ./search.js
// redirect event in middleware/record-redirect.js
// preference event in ./display-tool-specific-content.js
fetchSession().then(() => {
initPageAndExitEvent() // must come first
initLinkEvent()
initClipboardEvent()
initCopyButtonEvent()
initPrintEvent()
// survey event in ./survey.js
// experiment event in ./experiment.js
// search and search_result event in ./search.js
// redirect event in middleware/record-redirect.js
// preference event in ./display-tool-specific-content.js
})
}

Просмотреть файл

@ -1,5 +0,0 @@
export default function getCsrf() {
const csrfEl = document.querySelector('meta[name="csrf-token"]')
if (!csrfEl) return ''
return csrfEl.getAttribute('content')
}

Просмотреть файл

@ -0,0 +1,51 @@
import { useState, useEffect } from 'react'
import { useRouter } from 'next/router'
const MAX_CACHE = 5000 // milliseconds
const RETRY = 500 // milliseconds
type Session = {
isSignedIn: boolean
csrfToken: string
language: string // en, es, ja, cn
userLanguage: string // en, es, ja, cn
theme: object // colorMode, nightTheme, dayTheme
themeCSS: object // colorMode, nightTheme, dayTheme
}
let sessionCache: Session | null
let lastUpdate: number | null
function isCacheValid() {
return lastUpdate && Date.now() - lastUpdate < MAX_CACHE
}
export function getSession() {
return sessionCache
}
// This function must only be called in the browser
export async function fetchSession(): Promise<Session | null> {
if (isCacheValid()) return sessionCache
const response = await fetch('/api/session')
if (response.ok) {
sessionCache = await response.json()
lastUpdate = Date.now()
return sessionCache as Session
}
lastUpdate = null
sessionCache = null
await new Promise((resolve) => setTimeout(resolve, RETRY))
return fetchSession()
}
// React hook version
export function useSession() {
const [session, setSession] = useState<Session | null>(sessionCache)
const { asPath } = useRouter()
// Only call `fetchSession` on the client
useEffect(() => {
fetchSession().then((session) => setSession(session))
}, [asPath])
return session
}

Просмотреть файл

@ -127,10 +127,6 @@ function setHeaders(headers, res) {
}
function mutateCheeriobodyByRequest($, req) {
// A fresh CSRF token into the <meta> tag
const freshCsrfToken = req.csrfToken()
$('meta[name="csrf-token"]').attr('content', freshCsrfToken)
// Populate if you have the `dotcom_user` user cookie and it's truthy
const isDotComAuthenticated = Boolean(req.cookies?.dotcom_user)
@ -153,7 +149,6 @@ function mutateCheeriobodyByRequest($, req) {
// See https://github.com/cheeriojs/cheerio/releases/tag/v1.0.0-rc.11
// and https://github.com/cheeriojs/cheerio/pull/2509
const parsedNextData = JSON.parse(nextData.get()[0].children[0].data)
parsedNextData.props.csrfToken = freshCsrfToken
parsedNextData.props.dotComAuthenticatedContext.isDotComAuthenticated = isDotComAuthenticated
parsedNextData.props.languagesContext.userLanguage = req.context.userLanguage
parsedNextData.props.themeProps = {

Просмотреть файл

@ -16,7 +16,6 @@ import {
import { defaultComponentTheme } from 'lib/get-theme.js'
type MyAppProps = AppProps & {
csrfToken: string
isDotComAuthenticated: boolean
themeProps: typeof defaultComponentTheme & Pick<ThemeProviderProps, 'colorMode'>
languagesContext: LanguagesContextT
@ -25,7 +24,6 @@ type MyAppProps = AppProps & {
const MyApp = ({
Component,
pageProps,
csrfToken,
themeProps,
languagesContext,
dotComAuthenticatedContext,
@ -59,8 +57,6 @@ const MyApp = ({
name="google-site-verification"
content="c1kuD-K2HIVF635lypcsWPoD4kilo5-jA_wBFyT4uMY"
/>
<meta name="csrf-token" content={csrfToken} />
</Head>
<SSRProvider>
<ThemeProvider
@ -95,7 +91,6 @@ MyApp.getInitialProps = async (appContext: AppContext) => {
return {
...appProps,
themeProps: getTheme(req),
csrfToken: req?.csrfToken?.() || '',
languagesContext: { languages: req.context.languages, userLanguage: req.context.userLanguage },
dotComAuthenticatedContext: { isDotComAuthenticated: Boolean(req.cookies?.dotcom_user) },
}

Просмотреть файл

@ -165,15 +165,6 @@ describe('survey', () => {
})
})
describe('csrf meta', () => {
it('should have a csrf-token meta tag on the page', async () => {
await page.goto(
'http://localhost:4000/en/actions/getting-started-with-github-actions/about-github-actions'
)
await page.waitForSelector('meta[name="csrf-token"]')
})
})
describe('platform picker', () => {
// from tests/javascripts/user-agent.js
const userAgents = [

Просмотреть файл

@ -17,28 +17,6 @@ const serializeTheme = (theme) => {
describe('in-memory render caching', () => {
jest.setTimeout(30 * 1000)
test('second render should be a cache hit with different csrf-token', async () => {
const res = await get('/en')
// Because these are effectively end-to-end tests, you can't expect
// the first request to be a cache miss because another end-to-end
// test might have "warmed up" this endpoint.
expect(res.headers['x-middleware-cache']).toBeTruthy()
const $1 = cheerio.load(res.text)
const res2 = await get('/en')
expect(res2.headers['x-middleware-cache']).toBe('hit')
const $2 = cheerio.load(res2.text)
const csrfTokenHTML1 = $1('meta[name="csrf-token"]').attr('content')
const csrfTokenHTML2 = $2('meta[name="csrf-token"]').attr('content')
expect(csrfTokenHTML1).not.toBe(csrfTokenHTML2)
// The HTML is one thing, we also need to check that the
// __NEXT_DATA__ serialized (JSON) state is different.
const csrfTokenNEXT1 = getNextData($1).props.csrfToken
const csrfTokenNEXT2 = getNextData($2).props.csrfToken
expect(csrfTokenHTML1).toBe(csrfTokenNEXT1)
expect(csrfTokenHTML2).toBe(csrfTokenNEXT2)
expect(csrfTokenNEXT1).not.toBe(csrfTokenNEXT2)
})
test('second render should be a cache hit with different dotcom-auth', async () => {
// Anonymous first
const res = await get('/en')