UNPKG

next-csrf

Version:

CSRF mitigation library for Next.js

128 lines 20.6 kB
"use strict";exports.__esModule=true;exports.hasBasePath=hasBasePath;exports.addBasePath=addBasePath;exports.delBasePath=delBasePath;exports.isLocalURL=isLocalURL;exports.resolveHref=resolveHref;exports.markLoadingError=markLoadingError;exports.default=void 0;var _normalizeTrailingSlash=require("../../../client/normalize-trailing-slash");var _denormalizePagePath=require("../../server/denormalize-page-path");var _mitt=_interopRequireDefault(require("../mitt"));var _utils=require("../utils");var _isDynamic=require("./utils/is-dynamic");var _parseRelativeUrl=require("./utils/parse-relative-url");var _querystring=require("./utils/querystring");var _resolveRewrites=_interopRequireDefault(require("./utils/resolve-rewrites"));var _routeMatcher=require("./utils/route-matcher");var _routeRegex=require("./utils/route-regex");function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj};}/* global __NEXT_DATA__ */ // tslint:disable:no-console const basePath=process.env.__NEXT_ROUTER_BASEPATH||'';function buildCancellationError(){return Object.assign(new Error('Route Cancelled'),{cancelled:true});}function hasBasePath(path){return path===basePath||path.startsWith(basePath+'/');}function addBasePath(path){// we only add the basepath on relative urls return basePath&&path.startsWith('/')?path==='/'?(0,_normalizeTrailingSlash.normalizePathTrailingSlash)(basePath):basePath+path:path;}function delBasePath(path){return path.slice(basePath.length)||'/';}/** * Detects whether a given url is routable by the Next.js router (browser only). */function isLocalURL(url){if(url.startsWith('/'))return true;try{// absolute urls can be local if they are on the same origin const locationOrigin=(0,_utils.getLocationOrigin)();const resolved=new URL(url,locationOrigin);return resolved.origin===locationOrigin&&hasBasePath(resolved.pathname);}catch(_){return false;}}/** * Resolves a given hyperlink with a certain router state (basePath not included). * Preserves absolute urls. */function resolveHref(currentPath,href){// we use a dummy base url for relative urls const base=new URL(currentPath,'http://n');const urlAsString=typeof href==='string'?href:(0,_utils.formatWithValidation)(href);try{const finalUrl=new URL(urlAsString,base);finalUrl.pathname=(0,_normalizeTrailingSlash.normalizePathTrailingSlash)(finalUrl.pathname);// if the origin didn't change, it means we received a relative href return finalUrl.origin===base.origin?finalUrl.href.slice(finalUrl.origin.length):finalUrl.href;}catch(_){return urlAsString;}}const PAGE_LOAD_ERROR=Symbol('PAGE_LOAD_ERROR');function markLoadingError(err){return Object.defineProperty(err,PAGE_LOAD_ERROR,{});}function prepareUrlAs(router,url,as){// If url and as provided as an object representation, // we'll format them into the string version here. return{url:addBasePath(resolveHref(router.pathname,url)),as:as?addBasePath(resolveHref(router.pathname,as)):as};}const manualScrollRestoration=process.env.__NEXT_SCROLL_RESTORATION&&typeof window!=='undefined'&&'scrollRestoration'in window.history;function fetchRetry(url,attempts){return fetch(url,{// Cookies are required to be present for Next.js' SSG "Preview Mode". // Cookies may also be required for `getServerSideProps`. // // > `fetch` won’t send cookies, unless you set the credentials init // > option. // https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch // // > For maximum browser compatibility when it comes to sending & // > receiving cookies, always supply the `credentials: 'same-origin'` // > option instead of relying on the default. // https://github.com/github/fetch#caveats credentials:'same-origin'}).then(res=>{if(!res.ok){if(attempts>1&&res.status>=500){return fetchRetry(url,attempts-1);}throw new Error(`Failed to load static props`);}return res.json();});}function fetchNextData(dataHref,isServerRender){return fetchRetry(dataHref,isServerRender?3:1).catch(err=>{// We should only trigger a server-side transition if this was caused // on a client-side transition. Otherwise, we'd get into an infinite // loop. if(!isServerRender){markLoadingError(err);}throw err;});}class Router{/** * Map of all components loaded in `Router` */ // Static Data Cache constructor(_pathname,_query,_as,{initialProps,pageLoader,App,wrapApp,Component,initialStyleSheets,err,subscription,isFallback}){this.route=void 0;this.pathname=void 0;this.query=void 0;this.asPath=void 0;this.basePath=void 0;this.components=void 0;this.sdc={};this.sub=void 0;this.clc=void 0;this.pageLoader=void 0;this._bps=void 0;this.events=void 0;this._wrapApp=void 0;this.isSsr=void 0;this.isFallback=void 0;this._inFlightRoute=void 0;this._shallow=void 0;this.onPopState=e=>{const state=e.state;if(!state){// We get state as undefined for two reasons. // 1. With older safari (< 8) and older chrome (< 34) // 2. When the URL changed with # // // In the both cases, we don't need to proceed and change the route. // (as it's already changed) // But we can simply replace the state with the new changes. // Actually, for (1) we don't need to nothing. But it's hard to detect that event. // So, doing the following for (1) does no harm. const{pathname,query}=this;this.changeState('replaceState',(0,_utils.formatWithValidation)({pathname:addBasePath(pathname),query}),(0,_utils.getURL)());return;}if(!state.__N){return;}const{url,as,options}=state;const{pathname}=(0,_parseRelativeUrl.parseRelativeUrl)(url);// Make sure we don't re-render on initial load, // can be caused by navigating back from an external site if(this.isSsr&&as===this.asPath&&pathname===this.pathname){return;}// If the downstream application returns falsy, return. // They will then be responsible for handling the event. if(this._bps&&!this._bps(state)){return;}this.change('replaceState',url,as,Object.assign({},options,{shallow:options.shallow&&this._shallow}));};// represents the current component key this.route=(0,_normalizeTrailingSlash.removePathTrailingSlash)(_pathname);// set up the component cache (by route keys) this.components={};// We should not keep the cache, if there's an error // Otherwise, this cause issues when when going back and // come again to the errored page. if(_pathname!=='/_error'){this.components[this.route]={Component,styleSheets:initialStyleSheets,props:initialProps,err,__N_SSG:initialProps&&initialProps.__N_SSG,__N_SSP:initialProps&&initialProps.__N_SSP};}this.components['/_app']={Component:App,styleSheets:[/* /_app does not need its stylesheets managed */]};// Backwards compat for Router.router.events // TODO: Should be remove the following major version as it was never documented this.events=Router.events;this.pageLoader=pageLoader;this.pathname=_pathname;this.query=_query;// if auto prerendered and dynamic route wait to update asPath // until after mount to prevent hydration mismatch this.asPath=// @ts-ignore this is temporarily global (attached to window) (0,_isDynamic.isDynamicRoute)(_pathname)&&__NEXT_DATA__.autoExport?_pathname:_as;this.basePath=basePath;this.sub=subscription;this.clc=null;this._wrapApp=wrapApp;// make sure to ignore extra popState in safari on navigating // back from external site this.isSsr=true;this.isFallback=isFallback;if(typeof window!=='undefined'){// make sure "as" doesn't start with double slashes or else it can // throw an error as it's considered invalid if(_as.substr(0,2)!=='//'){// in order for `e.state` to work on the `onpopstate` event // we have to register the initial route upon initialization this.changeState('replaceState',(0,_utils.formatWithValidation)({pathname:addBasePath(_pathname),query:_query}),(0,_utils.getURL)());}window.addEventListener('popstate',this.onPopState);// enable custom scroll restoration handling when available // otherwise fallback to browser's default handling if(process.env.__NEXT_SCROLL_RESTORATION){if(manualScrollRestoration){window.history.scrollRestoration='manual';let scrollDebounceTimeout;const debouncedScrollSave=()=>{if(scrollDebounceTimeout)clearTimeout(scrollDebounceTimeout);scrollDebounceTimeout=setTimeout(()=>{const{url,as:curAs,options}=history.state;this.changeState('replaceState',url,curAs,Object.assign({},options,{_N_X:window.scrollX,_N_Y:window.scrollY}));},10);};window.addEventListener('scroll',debouncedScrollSave);}}}}reload(){window.location.reload();}/** * Go back in history */back(){window.history.back();}/** * Performs a `pushState` with arguments * @param url of the route * @param as masks `url` for the browser * @param options object you can define `shallow` and other options */push(url,as=url,options={}){;({url,as}=prepareUrlAs(this,url,as));return this.change('pushState',url,as,options);}/** * Performs a `replaceState` with arguments * @param url of the route * @param as masks `url` for the browser * @param options object you can define `shallow` and other options */replace(url,as=url,options={}){;({url,as}=prepareUrlAs(this,url,as));return this.change('replaceState',url,as,options);}async change(method,url,as,options){if(!isLocalURL(url)){window.location.href=url;return false;}if(!options._h){this.isSsr=false;}// marking route changes as a navigation start entry if(_utils.ST){performance.mark('routeChange');}if(this._inFlightRoute){this.abortComponentLoad(this._inFlightRoute);}const cleanedAs=hasBasePath(as)?delBasePath(as):as;this._inFlightRoute=as;// If the url change is only related to a hash change // We should not proceed. We should only change the state. // WARNING: `_h` is an internal option for handing Next.js client-side // hydration. Your app should _never_ use this property. It may change at // any time without notice. if(!options._h&&this.onlyAHashChange(cleanedAs)){this.asPath=cleanedAs;Router.events.emit('hashChangeStart',as);// TODO: do we need the resolved href when only a hash change? this.changeState(method,url,as,options);this.scrollToHash(cleanedAs);this.notify(this.components[this.route]);Router.events.emit('hashChangeComplete',as);return true;}// The build manifest needs to be loaded before auto-static dynamic pages // get their query parameters to allow ensuring they can be parsed properly // when rewritten to const pages=await this.pageLoader.getPageList();const{__rewrites:rewrites}=await this.pageLoader.promisedBuildManifest;let parsed=(0,_parseRelativeUrl.parseRelativeUrl)(url);let{pathname,searchParams}=parsed;parsed=this._resolveHref(parsed,pages);if(parsed.pathname!==pathname){pathname=parsed.pathname;url=(0,_utils.formatWithValidation)(parsed);}const query=(0,_querystring.searchParamsToUrlQuery)(searchParams);// url and as should always be prefixed with basePath by this // point by either next/link or router.push/replace so strip the // basePath from the pathname to match the pages dir 1-to-1 pathname=pathname?(0,_normalizeTrailingSlash.removePathTrailingSlash)(delBasePath(pathname)):pathname;// If asked to change the current URL we should reload the current page // (not location.reload() but reload getInitialProps and other Next.js stuffs) // We also need to set the method = replaceState always // as this should not go into the history (That's how browsers work) // We should compare the new asPath to the current asPath, not the url if(!this.urlIsNew(cleanedAs)){method='replaceState';}const route=(0,_normalizeTrailingSlash.removePathTrailingSlash)(pathname);const{shallow=false}=options;// we need to resolve the as value using rewrites for dynamic SSG // pages to allow building the data URL correctly let resolvedAs=as;if(process.env.__NEXT_HAS_REWRITES){resolvedAs=(0,_resolveRewrites.default)(as,pages,basePath,rewrites,query,p=>this._resolveHref({pathname:p},pages).pathname);}resolvedAs=delBasePath(resolvedAs);if((0,_isDynamic.isDynamicRoute)(route)){const{pathname:asPathname}=(0,_parseRelativeUrl.parseRelativeUrl)(resolvedAs);const routeRegex=(0,_routeRegex.getRouteRegex)(route);const routeMatch=(0,_routeMatcher.getRouteMatcher)(routeRegex)(asPathname);if(!routeMatch){const missingParams=Object.keys(routeRegex.groups).filter(param=>!query[param]);if(missingParams.length>0){if(process.env.NODE_ENV!=='production'){console.warn(`Mismatching \`as\` and \`href\` failed to manually provide `+`the params: ${missingParams.join(', ')} in the \`href\`'s \`query\``);}throw new Error(`The provided \`as\` value (${asPathname}) is incompatible with the \`href\` value (${route}). `+`Read more: https://err.sh/vercel/next.js/incompatible-href-as`);}}else{// Merge params into `query`, overwriting any specified in search Object.assign(query,routeMatch);}}Router.events.emit('routeChangeStart',as);try{const routeInfo=await this.getRouteInfo(route,pathname,query,as,shallow);let{error}=routeInfo;Router.events.emit('beforeHistoryChange',as);this.changeState(method,url,as,options);if(process.env.NODE_ENV!=='production'){const appComp=this.components['/_app'].Component;window.next.isPrerendered=appComp.getInitialProps===appComp.origGetInitialProps&&!routeInfo.Component.getInitialProps;}await this.set(route,pathname,query,cleanedAs,routeInfo).catch(e=>{if(e.cancelled)error=error||e;else throw e;});if(error){Router.events.emit('routeChangeError',error,cleanedAs);throw error;}if(process.env.__NEXT_SCROLL_RESTORATION){if(manualScrollRestoration&&'_N_X'in options){window.scrollTo(options._N_X,options._N_Y);}}Router.events.emit('routeChangeComplete',as);return true;}catch(err){if(err.cancelled){return false;}throw err;}}changeState(method,url,as,options={}){if(process.env.NODE_ENV!=='production'){if(typeof window.history==='undefined'){console.error(`Warning: window.history is not available.`);return;}if(typeof window.history[method]==='undefined'){console.error(`Warning: window.history.${method} is not available`);return;}}if(method!=='pushState'||(0,_utils.getURL)()!==as){this._shallow=options.shallow;window.history[method]({url,as,options,__N:true},// Most browsers currently ignores this parameter, although they may use it in the future. // Passing the empty string here should be safe against future changes to the method. // https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState '',as);}}async handleRouteInfoError(err,pathname,query,as,loadErrorFail){if(err.cancelled){// bubble up cancellation errors throw err;}if(PAGE_LOAD_ERROR in err||loadErrorFail){Router.events.emit('routeChangeError',err,as);// If we can't load the page it could be one of following reasons // 1. Page doesn't exists // 2. Page does exist in a different zone // 3. Internal error while loading the page // So, doing a hard reload is the proper way to deal with this. window.location.href=as;// Changing the URL doesn't block executing the current code path. // So let's throw a cancellation error stop the routing logic. throw buildCancellationError();}try{const{page:Component,styleSheets}=await this.fetchComponent('/_error');const routeInfo={Component,styleSheets,err,error:err};try{routeInfo.props=await this.getInitialProps(Component,{err,pathname,query});}catch(gipErr){console.error('Error in error page `getInitialProps`: ',gipErr);routeInfo.props={};}return routeInfo;}catch(routeInfoErr){return this.handleRouteInfoError(routeInfoErr,pathname,query,as,true);}}async getRouteInfo(route,pathname,query,as,shallow=false){try{const cachedRouteInfo=this.components[route];if(shallow&&cachedRouteInfo&&this.route===route){return cachedRouteInfo;}const routeInfo=cachedRouteInfo?cachedRouteInfo:await this.fetchComponent(route).then(res=>({Component:res.page,styleSheets:res.styleSheets,__N_SSG:res.mod.__N_SSG,__N_SSP:res.mod.__N_SSP}));const{Component,__N_SSG,__N_SSP}=routeInfo;if(process.env.NODE_ENV!=='production'){const{isValidElementType}=require('react-is');if(!isValidElementType(Component)){throw new Error(`The default export is not a React Component in page: "${pathname}"`);}}let dataHref;if(__N_SSG||__N_SSP){dataHref=this.pageLoader.getDataHref((0,_utils.formatWithValidation)({pathname,query}),delBasePath(as),__N_SSG);}const props=await this._getData(()=>__N_SSG?this._getStaticData(dataHref):__N_SSP?this._getServerData(dataHref):this.getInitialProps(Component,// we provide AppTree later so this needs to be `any` {pathname,query,asPath:as}));routeInfo.props=props;this.components[route]=routeInfo;return routeInfo;}catch(err){return this.handleRouteInfoError(err,pathname,query,as);}}set(route,pathname,query,as,data){this.isFallback=false;this.route=route;this.pathname=pathname;this.query=query;this.asPath=as;return this.notify(data);}/** * Callback to execute before replacing router state * @param cb callback to be executed */beforePopState(cb){this._bps=cb;}onlyAHashChange(as){if(!this.asPath)return false;const[oldUrlNoHash,oldHash]=this.asPath.split('#');const[newUrlNoHash,newHash]=as.split('#');// Makes sure we scroll to the provided hash if the url/hash are the same if(newHash&&oldUrlNoHash===newUrlNoHash&&oldHash===newHash){return true;}// If the urls are change, there's more than a hash change if(oldUrlNoHash!==newUrlNoHash){return false;}// If the hash has changed, then it's a hash only change. // This check is necessary to handle both the enter and // leave hash === '' cases. The identity case falls through // and is treated as a next reload. return oldHash!==newHash;}scrollToHash(as){const[,hash]=as.split('#');// Scroll to top if the hash is just `#` with no value if(hash===''){window.scrollTo(0,0);return;}// First we check if the element by id is found const idEl=document.getElementById(hash);if(idEl){idEl.scrollIntoView();return;}// If there's no element with the id, we check the `name` property // To mirror browsers const nameEl=document.getElementsByName(hash)[0];if(nameEl){nameEl.scrollIntoView();}}urlIsNew(asPath){return this.asPath!==asPath;}_resolveHref(parsedHref,pages){const{pathname}=parsedHref;const cleanPathname=(0,_denormalizePagePath.denormalizePagePath)(delBasePath(pathname));if(cleanPathname==='/404'||cleanPathname==='/_error'){return parsedHref;}// handle resolving href for dynamic routes if(!pages.includes(cleanPathname)){// eslint-disable-next-line array-callback-return pages.some(page=>{if((0,_isDynamic.isDynamicRoute)(page)&&(0,_routeRegex.getRouteRegex)(page).re.test(cleanPathname)){parsedHref.pathname=addBasePath(page);return true;}});}return parsedHref;}/** * Prefetch page code, you may wait for the data during page rendering. * This feature only works in production! * @param url the href of prefetched page * @param asPath the as path of the prefetched page */async prefetch(url,asPath=url,options={}){let parsed=(0,_parseRelativeUrl.parseRelativeUrl)(url);let{pathname}=parsed;const pages=await this.pageLoader.getPageList();parsed=this._resolveHref(parsed,pages);if(parsed.pathname!==pathname){pathname=parsed.pathname;url=(0,_utils.formatWithValidation)(parsed);}// Prefetch is not supported in development mode because it would trigger on-demand-entries if(process.env.NODE_ENV!=='production'){return;}const route=(0,_normalizeTrailingSlash.removePathTrailingSlash)(pathname);await Promise.all([this.pageLoader.prefetchData(url,asPath),this.pageLoader[options.priority?'loadPage':'prefetch'](route)]);}async fetchComponent(route){let cancelled=false;const cancel=this.clc=()=>{cancelled=true;};const componentResult=await this.pageLoader.loadPage(route);if(cancelled){const error=new Error(`Abort fetching component for route: "${route}"`);error.cancelled=true;throw error;}if(cancel===this.clc){this.clc=null;}return componentResult;}_getData(fn){let cancelled=false;const cancel=()=>{cancelled=true;};this.clc=cancel;return fn().then(data=>{if(cancel===this.clc){this.clc=null;}if(cancelled){const err=new Error('Loading initial props cancelled');err.cancelled=true;throw err;}return data;});}_getStaticData(dataHref){const{href:cacheKey}=new URL(dataHref,window.location.href);if(process.env.NODE_ENV==='production'&&this.sdc[cacheKey]){return Promise.resolve(this.sdc[cacheKey]);}return fetchNextData(dataHref,this.isSsr).then(data=>{this.sdc[cacheKey]=data;return data;});}_getServerData(dataHref){return fetchNextData(dataHref,this.isSsr);}getInitialProps(Component,ctx){const{Component:App}=this.components['/_app'];const AppTree=this._wrapApp(App);ctx.AppTree=AppTree;return(0,_utils.loadGetInitialProps)(App,{AppTree,Component,router:this,ctx});}abortComponentLoad(as){if(this.clc){Router.events.emit('routeChangeError',buildCancellationError(),as);this.clc();this.clc=null;}}notify(data){return this.sub(data,this.components['/_app'].Component);}}exports.default=Router;Router.events=(0,_mitt.default)(); //# sourceMappingURL=router.js.map