Skip to content

Commit 9dc51ed

Browse files
committed
Try to fetch remote guide from whitelisted hosts if not found locally
1 parent f0d3877 commit 9dc51ed

File tree

11 files changed

+234
-81
lines changed

11 files changed

+234
-81
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@
4848
"afterEach",
4949
"neo",
5050
"FileReader",
51-
"Blob"
51+
"Blob",
52+
"fetch"
5253
]
5354
},
5455
"repository": {},

src/browser/modules/Stream/PlayFrame.jsx

Lines changed: 62 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,36 +18,74 @@
1818
* along with this program. If not, see <http://www.gnu.org/licenses/>.
1919
*/
2020

21+
import { Component } from 'preact'
22+
import { withBus } from 'preact-suber'
23+
import { fetchGuideFromWhitelistAction } from 'shared/modules/commands/commandsDuck'
24+
2125
import Guides from '../Guides/Guides'
2226
import * as html from '../Guides/html'
2327
import FrameTemplate from './FrameTemplate'
2428
import { splitStringOnFirst } from 'services/commandUtils'
29+
import ErrorsView from './Views/ErrorsView'
2530

26-
const PlayFrame = ({frame}) => {
27-
let guide = 'Play guide not specified'
28-
if (frame.result) {
29-
guide = <Guides withDirectives html={frame.result} />
30-
} else {
31-
const guideName = splitStringOnFirst(frame.cmd, ' ')[1].toLowerCase().replace(/\s|-/g, '').trim() || 'start'
32-
if (guideName !== '') {
33-
const content = html[guideName]
34-
if (content !== undefined) {
35-
guide = <Guides withDirectives html={content} />
36-
} else {
37-
if (frame.error && frame.error.error) {
38-
guide = frame.error.error
39-
} else {
40-
guide = <Guides withDirectives html={html['unfound']} />
31+
export class PlayFrame extends Component {
32+
constructor (props) {
33+
super(props)
34+
this.state = {
35+
guide: null
36+
}
37+
}
38+
componentDidMount () {
39+
if (this.props.frame.result) { // Found remote guide
40+
this.setState({ guide: <Guides withDirectives html={this.props.frame.result} /> })
41+
return
42+
}
43+
if (this.props.frame.response && this.props.frame.error && this.props.frame.error.error) { // Not found remotely (or other error)
44+
if (this.props.frame.response.status === 404) return this.setState({ guide: <Guides withDirectives html={html['unfound']} /> })
45+
return this.setState({
46+
guide: (
47+
<ErrorsView
48+
error={{
49+
message: 'Error: The remote server responded with the following error: ' + this.props.frame.response.status,
50+
code: 'Remote guide error'
51+
}}
52+
/>)
53+
})
54+
}
55+
if (this.props.frame.error && this.props.frame.error.error) { // Some other error. Whitelist error etc.
56+
return this.setState({ guide: <ErrorsView error={{
57+
message: this.props.frame.error.error,
58+
code: 'Remote guide error'
59+
}} /> })
60+
}
61+
const guideName = splitStringOnFirst(this.props.frame.cmd, ' ')[1].toLowerCase().replace(/\s|-/g, '').trim() || 'start'
62+
if (html[guideName] !== undefined) { // Found it locally
63+
this.setState({ guide: <Guides withDirectives html={html[guideName]} /> })
64+
return
65+
}
66+
// Not found remotely or locally
67+
// Try to find it remotely by name
68+
if (this.props.bus) {
69+
const action = fetchGuideFromWhitelistAction(guideName)
70+
this.props.bus.self(action.type, action, (res) => {
71+
if (!res.success) { // No luck
72+
return this.setState({ guide: <Guides withDirectives html={html['unfound']} /> })
4173
}
42-
}
74+
this.setState({ guide: <Guides withDirectives html={res.result} /> })
75+
})
76+
} else { // No bus. Give up
77+
return this.setState({ guide: <Guides withDirectives html={html['unfound']} /> })
4378
}
4479
}
45-
return (
46-
<FrameTemplate
47-
className='playFrame'
48-
header={frame}
49-
contents={guide}
50-
/>
51-
)
80+
render () {
81+
return (
82+
<FrameTemplate
83+
className='playFrame'
84+
header={this.props.frame}
85+
contents={this.state.guide}
86+
/>
87+
)
88+
}
5289
}
53-
export default PlayFrame
90+
91+
export default withBus(PlayFrame)

src/shared/modules/commands/commandsDuck.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,16 @@
1818
* along with this program. If not, see <http://www.gnu.org/licenses/>.
1919
*/
2020

21+
import Rx from 'rxjs'
2122
import { getInterpreter, isNamedInterpreter, cleanCommand, extractPostConnectCommandsFromServerConfig } from 'services/commandUtils'
23+
import { extractWhitelistFromConfigString, addProtocolsToUrlList, firstSuccessPromise } from 'services/utils'
2224
import { hydrate } from 'services/duckUtils'
2325
import helper from 'services/commandInterpreterHelper'
2426
import { addHistory } from '../history/historyDuck'
2527
import { getCmdChar, getMaxHistory } from '../settings/settingsDuck'
28+
import { fetchRemoteGuide } from './helpers/play'
2629
import { CONNECTION_SUCCESS } from '../connections/connectionsDuck'
27-
import { UPDATE_SETTINGS, getAvailableSettings, fetchMetaData } from '../dbMeta/dbMetaDuck'
30+
import { UPDATE_SETTINGS, getAvailableSettings, fetchMetaData, getRemoteContentHostnameWhitelist } from '../dbMeta/dbMetaDuck'
2831
import { USER_CLEAR } from 'shared/modules/app/appDuck'
2932

3033
export const NAME = 'commands'
@@ -36,6 +39,7 @@ export const SHOW_ERROR_MESSAGE = NAME + '/SHOW_ERROR_MESSAGE'
3639
export const CYPHER = NAME + '/CYPHER'
3740
export const CYPHER_SUCCEEDED = NAME + '/CYPHER_SUCCEEDED'
3841
export const CYPHER_FAILED = NAME + '/CYPHER_FAILED'
42+
export const FETCH_GUIDE_FROM_WHITELIST = NAME + 'FETCH_GUIDE_FROM_WHITELIST'
3943

4044
const initialState = {
4145
lastCommandWasUnknown: false
@@ -91,6 +95,7 @@ export const showErrorMessage = (errorMessage) => ({
9195
export const cypher = (query) => ({ type: CYPHER, query })
9296
export const successfulCypher = (query) => ({ type: CYPHER_SUCCEEDED, query })
9397
export const unsuccessfulCypher = (query) => ({ type: CYPHER_FAILED, query })
98+
export const fetchGuideFromWhitelistAction = (url) => ({ type: FETCH_GUIDE_FROM_WHITELIST, url })
9499

95100
// Epics
96101
export const handleCommandsEpic = (action$, store) =>
@@ -143,3 +148,18 @@ export const postConnectCmdEpic = (some$, store) =>
143148
}
144149
})
145150
.mapTo({ type: 'NOOP' })
151+
152+
export const fetchGuideFromWhitelistEpic = (some$, store) =>
153+
some$.ofType(FETCH_GUIDE_FROM_WHITELIST)
154+
.mergeMap((action) => {
155+
if (!action.$$responseChannel || !action.url) return Rx.Observable.of({ type: 'NOOP' })
156+
const whitelistStr = getRemoteContentHostnameWhitelist(store.getState())
157+
const whitelist = extractWhitelistFromConfigString(whitelistStr)
158+
const urlWhitelist = addProtocolsToUrlList(whitelist)
159+
const guidesUrls = urlWhitelist.map((url) => url + '/' + action.url)
160+
return firstSuccessPromise(guidesUrls, (url) => { // Get first successful fetch
161+
return fetchRemoteGuide(url, whitelistStr)
162+
.then((r) => ({type: action.$$responseChannel, success: true, result: r}))
163+
})
164+
.catch((e) => ({type: action.$$responseChannel, success: false, error: e})) // If all fails, report that
165+
})
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* Copyright (c) 2002-2017 "Neo Technology,"
3+
* Network Engine for Objects in Lund AB [http://neotechnology.com]
4+
*
5+
* This file is part of Neo4j.
6+
*
7+
* Neo4j is free software: you can redistribute it and/or modify
8+
* it under the terms of the GNU General Public License as published by
9+
* the Free Software Foundation, either version 3 of the License, or
10+
* (at your option) any later version.
11+
*
12+
* This program is distributed in the hope that it will be useful,
13+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
* GNU General Public License for more details.
16+
*
17+
* You should have received a copy of the GNU General Public License
18+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
19+
*/
20+
21+
import { hostIsAllowed } from 'services/utils'
22+
import { cleanHtml } from 'services/remoteUtils'
23+
import remote from 'services/remote'
24+
25+
export const fetchRemoteGuide = (url, whitelist = null) => {
26+
return new Promise((resolve, reject) => {
27+
if (!hostIsAllowed(url, whitelist)) {
28+
return reject(new Error('Hostname is not allowed according to server whitelist'))
29+
}
30+
resolve()
31+
}).then(() => {
32+
return remote.get(url).then((r) => {
33+
return cleanHtml(r)
34+
})
35+
})
36+
}

src/shared/modules/dbMeta/__snapshots__/dbMetaDuck.test.js.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Object {
1414
},
1515
"settings": Object {
1616
"browser.allow_outgoing_connections": false,
17+
"browser.remote_content_hostname_whitelist": "guides.neo4j.com, localhost",
1718
},
1819
}
1920
`;
@@ -33,6 +34,7 @@ Object {
3334
},
3435
"settings": Object {
3536
"browser.allow_outgoing_connections": false,
37+
"browser.remote_content_hostname_whitelist": "guides.neo4j.com, localhost",
3638
},
3739
"shouldKeep": true,
3840
}

src/shared/modules/dbMeta/dbMetaDuck.js

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,22 +40,6 @@ export const UPDATE_SETTINGS = 'meta/UPDATE_SETTINGS'
4040
export const CLEAR = 'meta/CLEAR'
4141
export const FORCE_FETCH = 'meta/FORCE_FETCH'
4242

43-
const initialState = {
44-
labels: [],
45-
relationshipTypes: [],
46-
properties: [],
47-
functions: [],
48-
procedures: [],
49-
server: {
50-
version: null,
51-
edition: null,
52-
storeId: null
53-
},
54-
settings: {
55-
'browser.allow_outgoing_connections': false
56-
}
57-
}
58-
5943
/**
6044
* Selectors
6145
*/
@@ -86,6 +70,7 @@ export const getStoreId = (state) => state[NAME].server.storeId
8670
export const getAvailableSettings = (state) => (state[NAME] || initialState).settings
8771
export const allowOutgoingConnections = (state) => getAvailableSettings(state)['browser.allow_outgoing_connections']
8872
export const credentialsTimeout = (state) => getAvailableSettings(state)['browser.credential_timeout'] || 0
73+
export const getRemoteContentHostnameWhitelist = (state) => getAvailableSettings(state)['browser.remote_content_hostname_whitelist'] || initialState.settings['browser.remote_content_hostname_whitelist']
8974
export const shouldRetainConnectionCredentials = (state) => {
9075
const conf = getAvailableSettings(state)['browser.retain_connection_credentials']
9176
if (conf === null || typeof conf === 'undefined') return true
@@ -141,6 +126,24 @@ function updateMetaForContext (state, meta, context) {
141126
}
142127
}
143128

129+
// Initial state
130+
const initialState = {
131+
labels: [],
132+
relationshipTypes: [],
133+
properties: [],
134+
functions: [],
135+
procedures: [],
136+
server: {
137+
version: null,
138+
edition: null,
139+
storeId: null
140+
},
141+
settings: {
142+
'browser.allow_outgoing_connections': false,
143+
'browser.remote_content_hostname_whitelist': 'guides.neo4j.com, localhost'
144+
}
145+
}
146+
144147
/**
145148
* Reducer
146149
*/

src/shared/rootEpic.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
*/
2020

2121
import { combineEpics } from 'redux-observable'
22-
import { handleCommandsEpic, postConnectCmdEpic } from './modules/commands/commandsDuck'
22+
import { handleCommandsEpic, postConnectCmdEpic, fetchGuideFromWhitelistEpic } from './modules/commands/commandsDuck'
2323
import { retainCredentialsSettingsEpic, checkSettingsForRoutingDriver, connectEpic, disconnectEpic, startupConnectEpic, disconnectSuccessEpic, startupConnectionSuccessEpic, startupConnectionFailEpic, detectActiveConnectionChangeEpic, connectionLostEpic } from './modules/connections/connectionsDuck'
2424
import { dbMetaEpic, clearMetaOnDisconnectEpic } from './modules/dbMeta/dbMetaDuck'
2525
import { cancelRequestEpic } from './modules/requests/requestsDuck'
@@ -36,6 +36,7 @@ import { maxFramesConfigEpic } from './modules/stream/streamDuck'
3636
export default combineEpics(
3737
handleCommandsEpic,
3838
postConnectCmdEpic,
39+
fetchGuideFromWhitelistEpic,
3940
connectionLostEpic,
4041
retainCredentialsSettingsEpic,
4142
checkSettingsForRoutingDriver,

src/shared/services/commandInterpreterHelper.js

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,9 @@ import { getHistory } from 'shared/modules/history/historyDuck'
2323
import { update as updateQueryResult } from 'shared/modules/requests/requestsDuck'
2424
import { getParams } from 'shared/modules/params/paramsDuck'
2525
import { updateGraphStyleData } from 'shared/modules/grass/grassDuck'
26-
import { cleanHtml } from 'services/remoteUtils'
27-
import { hostIsAllowed } from 'services/utils'
26+
import { getRemoteContentHostnameWhitelist } from 'shared/modules/dbMeta/dbMetaDuck'
27+
import { fetchRemoteGuide } from 'shared/modules/commands/helpers/play'
2828
import remote from 'services/remote'
29-
import { getServerConfig } from 'services/bolt/boltHelpers'
3029
import { handleServerCommand } from 'shared/modules/commands/helpers/server'
3130
import { handleCypherCommand } from 'shared/modules/commands/helpers/cypher'
3231
import { unknownCommand, showErrorMessage, cypher, successfulCypher, unsuccessfulCypher } from 'shared/modules/commands/commandsDuck'
@@ -119,23 +118,13 @@ const availableCommands = [{
119118
match: (cmd) => /^play(\s|$)https?/.test(cmd),
120119
exec: function (action, cmdchar, put, store) {
121120
const url = action.cmd.substr(cmdchar.length + 'play '.length)
122-
123-
getServerConfig(['browser.']).then((conf) => {
124-
const serverWhitelist = conf && conf['browser.remote_content_hostname_whitelist']
125-
const whitelist = serverWhitelist || null
126-
127-
if (!hostIsAllowed(url, whitelist)) {
128-
throw new Error('Hostname is not allowed according to server whitelist')
129-
}
130-
remote.get(url)
131-
.then((r) => {
132-
put(frames.add({...action, type: 'play-remote', result: cleanHtml(r)}))
133-
}).catch((e) => {
134-
put(frames.add({...action, type: 'play-remote', error: CouldNotFetchRemoteGuideError(e.name + ': ' + e.message)}))
135-
})
136-
}).catch((e) => {
137-
put(frames.add({...action, type: 'play-remote', error: CouldNotFetchRemoteGuideError(e.name + ': ' + e.message)}))
138-
})
121+
const whitelist = getRemoteContentHostnameWhitelist(store.getState())
122+
fetchRemoteGuide(url, whitelist)
123+
.then((r) => {
124+
put(frames.add({...action, type: 'play-remote', result: r}))
125+
}).catch((e) => {
126+
put(frames.add({...action, type: 'play-remote', response: (e.response || null), error: CouldNotFetchRemoteGuideError(e.name + ': ' + e.message)}))
127+
})
139128
}
140129
}, {
141130
name: 'play',

src/shared/services/remote.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
* along with this program. If not, see <http://www.gnu.org/licenses/>.
1919
*/
2020

21-
import fetch from 'isomorphic-fetch'
21+
/* global fetch */
22+
import 'isomorphic-fetch'
2223

2324
function request (method, url, data = null) {
2425
return fetch(url, {
@@ -30,12 +31,15 @@ function request (method, url, data = null) {
3031
},
3132
body: data
3233
})
34+
.then(checkStatus)
3335
}
3436

3537
function get (url) {
3638
return fetch(url, {
3739
method: 'get'
38-
}).then(function (response) {
40+
})
41+
.then(checkStatus)
42+
.then(function (response) {
3943
return response.text()
4044
})
4145
}
@@ -53,6 +57,16 @@ function getJSON (url) {
5357
})
5458
}
5559

60+
function checkStatus (response) {
61+
if (response.status >= 200 && response.status < 300) {
62+
return response
63+
} else {
64+
var error = new Error(response.status + ' ' + response.statusText)
65+
error.response = response
66+
throw error
67+
}
68+
}
69+
5670
export default {
5771
get,
5872
getJSON,

0 commit comments

Comments
 (0)