Skip to content

Commit 4c96ef6

Browse files
committed
[dashboard, ws-proxy, supervisor] Break potential DDOS cycle by disabling autostart
When triggered: a) inFrame or b) when redirect from IDE url (by ws-proxy)
1 parent 2da01c7 commit 4c96ef6

File tree

7 files changed

+137
-45
lines changed

7 files changed

+137
-45
lines changed

components/dashboard/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"js-cookie": "^3.0.1",
1010
"moment": "^2.29.1",
1111
"monaco-editor": "^0.25.2",
12+
"query-string": "^7.1.1",
1213
"react": "^17.0.1",
1314
"react-dom": "^17.0.1",
1415
"react-router-dom": "^5.2.0",

components/dashboard/src/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { settingsPathAccount, settingsPathIntegrations, settingsPathMain, settin
2727
import { projectsPathInstallGitHubApp, projectsPathMain, projectsPathMainWithParams, projectsPathNew } from './projects/projects.routes';
2828
import { refreshSearchData } from './components/RepositoryFinder';
2929
import { StartWorkspaceModal } from './workspaces/StartWorkspaceModal';
30+
import { parseProps } from './start/StartWorkspace';
3031

3132
const Setup = React.lazy(() => import(/* webpackPrefetch: true */ './Setup'));
3233
const Workspaces = React.lazy(() => import(/* webpackPrefetch: true */ './workspaces/Workspaces'));
@@ -412,7 +413,7 @@ function App() {
412413
} else if (isCreation) {
413414
toRender = <CreateWorkspace contextUrl={hash} />;
414415
} else if (isWsStart) {
415-
toRender = <StartWorkspace workspaceId={hash} />;
416+
toRender = <StartWorkspace {...parseProps(hash, window.location.search)} />;
416417
} else if (/^(github|gitlab)\.com\/.+?/i.test(window.location.pathname)) {
417418
let url = new URL(window.location.href)
418419
url.hash = url.pathname

components/dashboard/src/start/StartWorkspace.tsx

Lines changed: 119 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ContextURL, DisposableCollection, GitpodServer, RateLimiterError, Start
88
import { IDEOptions } from "@gitpod/gitpod-protocol/lib/ide-protocol";
99
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
1010
import EventEmitter from "events";
11+
import * as queryString from "query-string";
1112
import React, { Suspense, useEffect } from "react";
1213
import { v4 } from 'uuid';
1314
import Arrow from "../components/Arrow";
@@ -21,10 +22,51 @@ const sessionId = v4();
2122
const WorkspaceLogs = React.lazy(() => import('../components/WorkspaceLogs'));
2223

2324
export interface StartWorkspaceProps {
24-
workspaceId: string;
25+
workspaceId: string,
26+
runsInIFrame: boolean,
27+
/**
28+
* This flag is used to break the autostart-cycle explained in https://github.com/gitpod-io/gitpod/issues/8043
29+
*/
30+
dontAutostart?: boolean,
31+
}
32+
33+
export function parseProps(workspaceId: string, search?: string): StartWorkspaceProps {
34+
const params = parseParameters(search);
35+
const notFound = !!(params && params["not_found"]);
36+
const runsInIFrame = window.top !== window.self;
37+
return {
38+
workspaceId,
39+
runsInIFrame: window.top !== window.self,
40+
// Either:
41+
// - not_found: we were sent back from a workspace cluster/IDE URL where we expected a workspace to be running but it wasn't because either:
42+
// - this is a (very) old tab and the workspace already timed out
43+
// - due to a start error our workspace terminated very quickly between:
44+
// a) us being redirected to that IDEUrl (based on the first ws-manager update) and
45+
// b) our requests being validated by ws-proxy
46+
// - runsInIFrame (IDE case):
47+
// - we assume the workspace has already been started for us
48+
// - we don't know it's instanceId
49+
dontAutostart: notFound || runsInIFrame,
50+
}
51+
}
52+
53+
function parseParameters(search?: string) {
54+
try {
55+
if (search === undefined) {
56+
return undefined;
57+
}
58+
return queryString.parse(search, {parseBooleans: true});
59+
} catch (err) {
60+
console.error("/start: error parsing search params", err);
61+
return undefined;
62+
}
2563
}
2664

2765
export interface StartWorkspaceState {
66+
/**
67+
* This is set to the istanceId we started (think we started on).
68+
* We only receive updates for this particular instance, or none if not set.
69+
*/
2870
startedInstanceId?: string;
2971
workspaceInstance?: WorkspaceInstance;
3072
workspace?: Workspace;
@@ -34,8 +76,8 @@ export interface StartWorkspaceState {
3476
link: string
3577
label: string
3678
clientID?: string
37-
}
38-
ideOptions?: IDEOptions
79+
};
80+
ideOptions?: IDEOptions;
3981
}
4082

4183
export default class StartWorkspace extends React.Component<StartWorkspaceProps, StartWorkspaceState> {
@@ -47,7 +89,7 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
4789

4890
private readonly toDispose = new DisposableCollection();
4991
componentWillMount() {
50-
if (this.runsInIFrame()) {
92+
if (this.props.runsInIFrame) {
5193
window.parent.postMessage({ type: '$setSessionId', sessionId }, '*');
5294
const setStateEventListener = (event: MessageEvent) => {
5395
if (event.data.type === 'setState' && 'state' in event.data && typeof event.data['state'] === 'object') {
@@ -75,8 +117,15 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
75117
this.setState({ error });
76118
}
77119

120+
if (this.props.dontAutostart) {
121+
this.fetchWorkspaceInfo(undefined);
122+
return;
123+
}
124+
125+
// dashboard case (no previous errors): start workspace as quickly as possible
78126
this.startWorkspace();
79-
getGitpodService().server.getIDEOptions().then(ideOptions => this.setState({ ideOptions }))
127+
// query IDE options so we can show them if necessary once the workspace is running
128+
getGitpodService().server.getIDEOptions().then(ideOptions => this.setState({ ideOptions }));
80129
}
81130

82131
componentWillUnmount() {
@@ -130,14 +179,13 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
130179
}
131180
console.log("/start: started workspace instance: " + result.instanceID);
132181
// redirect to workspaceURL if we are not yet running in an iframe
133-
if (!this.runsInIFrame() && result.workspaceURL) {
182+
if (!this.props.runsInIFrame && result.workspaceURL) {
134183
this.redirectTo(result.workspaceURL);
135184
return;
136185
}
137-
this.setState({ startedInstanceId: result.instanceID });
138-
// Explicitly query state to guarantee we get at least one update
186+
// Start listening too instance updates - and explicitly query state once to guarantee we get at least one update
139187
// (needed for already started workspaces, and not hanging in 'Starting ...' for too long)
140-
this.fetchWorkspaceInfo();
188+
this.fetchWorkspaceInfo(result.instanceID);
141189
} catch (error) {
142190
console.error(error);
143191
if (typeof error === 'string') {
@@ -180,15 +228,28 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
180228
}
181229
}
182230

183-
async fetchWorkspaceInfo() {
231+
/**
232+
* Fetches initial WorkspaceInfo from the server. If there is a WorkspaceInstance for workspaceId, we feed it
233+
* into "onInstanceUpdate" and start accepting further updates.
234+
*
235+
* @param startedInstanceId The instanceId we want to listen on
236+
*/
237+
async fetchWorkspaceInfo(startedInstanceId: string | undefined) {
238+
// this ensures we're receiving updates for this instance
239+
if (startedInstanceId) {
240+
this.setState({ startedInstanceId });
241+
}
242+
184243
const { workspaceId } = this.props;
185244
try {
186245
const info = await getGitpodService().server.getWorkspace(workspaceId);
187246
if (info.latestInstance) {
188-
this.setState({
189-
workspace: info.workspace
190-
});
191-
this.onInstanceUpdate(info.latestInstance);
247+
const instance = info.latestInstance;
248+
this.setState((s) => ({
249+
workspace: info.workspace,
250+
startedInstanceId: s.startedInstanceId || instance.id, // note: here's a potential mismatch between startedInstanceId and instance.id. TODO(gpl) How to handle this?
251+
}));
252+
this.onInstanceUpdate(instance);
192253
}
193254
} catch (error) {
194255
console.error(error);
@@ -197,20 +258,35 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
197258
}
198259

199260
notifyDidOpenConnection() {
200-
this.fetchWorkspaceInfo();
261+
this.fetchWorkspaceInfo(undefined);
201262
}
202263

203264
async onInstanceUpdate(workspaceInstance: WorkspaceInstance) {
204-
const startedInstanceId = this.state?.startedInstanceId;
205-
if (workspaceInstance.workspaceId !== this.props.workspaceId || startedInstanceId !== workspaceInstance.id) {
265+
if (workspaceInstance.workspaceId !== this.props.workspaceId) {
206266
return;
207267
}
208268

269+
// Here we filter out updates to instances we haven't started to avoid issues with updates coming in out-of-order
270+
// (e.g., multiple "stopped" events from the older instance, where we already started a fresh one after the first)
271+
// Only exception is when we do the switch from the "old" to the "new" one.
272+
const startedInstanceId = this.state?.startedInstanceId;
273+
if (startedInstanceId !== workspaceInstance.id) {
274+
// do we want to switch to "new" instance we just received an update for?
275+
const switchToNewInstance = this.state.workspaceInstance?.status.phase === "stopped" && workspaceInstance.status.phase !== "stopped";
276+
if (!switchToNewInstance) {
277+
return;
278+
}
279+
this.setState({
280+
startedInstanceId: workspaceInstance.id,
281+
workspaceInstance,
282+
});
283+
}
284+
209285
await this.ensureWorkspaceAuth(workspaceInstance.id);
210286

211287
// Redirect to workspaceURL if we are not yet running in an iframe.
212288
// It happens this late if we were waiting for a docker build.
213-
if (!this.runsInIFrame() && workspaceInstance.ideUrl) {
289+
if (!this.props.runsInIFrame && workspaceInstance.ideUrl && !this.props.dontAutostart) {
214290
this.redirectTo(workspaceInstance.ideUrl);
215291
return;
216292
}
@@ -255,22 +331,18 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
255331
}
256332

257333
redirectTo(url: string) {
258-
if (this.runsInIFrame()) {
334+
if (this.props.runsInIFrame) {
259335
window.parent.postMessage({ type: 'relocate', url }, '*');
260336
} else {
261337
window.location.href = url;
262338
}
263339
}
264340

265-
runsInIFrame() {
266-
return window.top !== window.self;
267-
}
268-
269341
render() {
270342
const { error } = this.state;
271343
const isHeadless = this.state.workspace?.type !== 'regular';
272344
const isPrebuilt = WithPrebuild.is(this.state.workspace?.context);
273-
let phase = StartPhase.Preparing;
345+
let phase: StartPhase | undefined = StartPhase.Preparing;
274346
let title = undefined;
275347
let statusMessage = !!error ? undefined : <p className="text-base text-gray-400">Preparing workspace …</p>;
276348
const contextURL = ContextURL.getNormalizedURL(this.state.workspace)?.toString();
@@ -317,7 +389,29 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
317389
}
318390
if (!this.state.desktopIde) {
319391
phase = StartPhase.Running;
320-
statusMessage = <p className="text-base text-gray-400">Opening Workspace …</p>;
392+
393+
if (this.props.dontAutostart) {
394+
// hide the progress bar, as we're already running
395+
phase = undefined;
396+
title = 'Running';
397+
398+
// in case we dontAutostart the IDE we have to provide controls to do so
399+
statusMessage = <div>
400+
<div className="flex space-x-3 items-center text-left rounded-xl m-auto px-4 h-16 w-72 mt-4 mb-2 bg-gray-100 dark:bg-gray-800">
401+
<div className="rounded-full w-3 h-3 text-sm bg-green-500">&nbsp;</div>
402+
<div>
403+
<p className="text-gray-700 dark:text-gray-200 font-semibold w-56 truncate">{this.state.workspaceInstance.workspaceId}</p>
404+
<a target="_parent" href={contextURL}><p className="w-56 truncate hover:text-blue-600 dark:hover:text-blue-400" >{contextURL}</p></a>
405+
</div>
406+
</div>
407+
<div className="mt-10 justify-center flex space-x-2">
408+
<a target="_parent" href={gitpodHostUrl.asDashboard().toString()}><button className="secondary">Go to Dashboard</button></a>
409+
<a target="_parent" href={gitpodHostUrl.asStart(this.props.workspaceId).toString() /** move over 'start' here to fetch fresh credentials in case this is an older tab */}><button>Open Workspace</button></a>
410+
</div>
411+
</div>;
412+
} else {
413+
statusMessage = <p className="text-base text-gray-400">Opening Workspace …</p>;
414+
}
321415
} else {
322416
phase = StartPhase.IdeReady;
323417
const openLink = this.state.desktopIde.link;

components/supervisor/frontend/src/index.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,6 @@ const toStop = new DisposableCollection();
106106

107107
//#region current-frame
108108
let current: HTMLElement = loading.frame;
109-
let stopped = false;
110109
let desktopRedirected = false;
111110
const nextFrame = () => {
112111
const instance = gitpodServiceClient.info.latestInstance;
@@ -142,19 +141,6 @@ const toStop = new DisposableCollection();
142141
return document.body;
143142
}
144143
}
145-
if (instance.status.phase === 'stopped') {
146-
stopped = true;
147-
}
148-
if (stopped && (
149-
instance.status.phase === 'preparing' ||
150-
instance.status.phase === 'pending' ||
151-
instance.status.phase === 'creating' ||
152-
instance.status.phase === 'initializing')) {
153-
// reload the page if the workspace was restarted to ensure:
154-
// - graceful reconnection of IDEs
155-
// - new owner token is set
156-
window.location.href = startUrl.toString();
157-
}
158144
}
159145
return loading.frame;
160146
}

components/ws-proxy/pkg/proxy/routes.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -550,7 +550,7 @@ func workspaceMustExistHandler(config *Config, infoProvider WorkspaceInfoProvide
550550
info := infoProvider.WorkspaceInfo(coords.ID)
551551
if info == nil {
552552
log.WithFields(log.OWI("", coords.ID, "")).Info("no workspace info found - redirecting to start")
553-
redirectURL := fmt.Sprintf("%s://%s/start/#%s", config.GitpodInstallation.Scheme, config.GitpodInstallation.HostName, coords.ID)
553+
redirectURL := fmt.Sprintf("%s://%s/start/?not_found=true#%s", config.GitpodInstallation.Scheme, config.GitpodInstallation.HostName, coords.ID)
554554
http.Redirect(resp, req, redirectURL, http.StatusFound)
555555
return
556556
}

components/ws-proxy/pkg/proxy/routes_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -414,10 +414,10 @@ func TestRoutes(t *testing.T) {
414414
Status: http.StatusFound,
415415
Header: http.Header{
416416
"Content-Type": {"text/html; charset=utf-8"},
417-
"Location": {"https://test-domain.com/start/#blabla-smelt-9ba20cc1"},
417+
"Location": {"https://test-domain.com/start/?not_found=true#blabla-smelt-9ba20cc1"},
418418
"Vary": {"Accept-Encoding"},
419419
},
420-
Body: ("<a href=\"https://test-domain.com/start/#blabla-smelt-9ba20cc1\">Found</a>.\n\n"),
420+
Body: ("<a href=\"https://test-domain.com/start/?not_found=true#blabla-smelt-9ba20cc1\">Found</a>.\n\n"),
421421
},
422422
},
423423
{
@@ -429,10 +429,10 @@ func TestRoutes(t *testing.T) {
429429
Status: http.StatusFound,
430430
Header: http.Header{
431431
"Content-Type": {"text/html; charset=utf-8"},
432-
"Location": {"https://test-domain.com/start/#blabla-smelt-9ba20cc1"},
432+
"Location": {"https://test-domain.com/start/?not_found=true#blabla-smelt-9ba20cc1"},
433433
"Vary": {"Accept-Encoding"},
434434
},
435-
Body: ("<a href=\"https://test-domain.com/start/#blabla-smelt-9ba20cc1\">Found</a>.\n\n"),
435+
Body: ("<a href=\"https://test-domain.com/start/?not_found=true#blabla-smelt-9ba20cc1\">Found</a>.\n\n"),
436436
},
437437
},
438438
{

yarn.lock

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14397,6 +14397,16 @@ query-string@^6.13.3:
1439714397
split-on-first "^1.0.0"
1439814398
strict-uri-encode "^2.0.0"
1439914399

14400+
query-string@^7.1.1:
14401+
version "7.1.1"
14402+
resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.1.1.tgz#754620669db978625a90f635f12617c271a088e1"
14403+
integrity sha512-MplouLRDHBZSG9z7fpuAAcI7aAYjDLhtsiVZsevsfaHWDS2IDdORKbSd1kWUA+V4zyva/HZoSfpwnYMMQDhb0w==
14404+
dependencies:
14405+
decode-uri-component "^0.2.0"
14406+
filter-obj "^1.1.0"
14407+
split-on-first "^1.0.0"
14408+
strict-uri-encode "^2.0.0"
14409+
1440014410
querystring-es3@^0.2.0:
1440114411
version "0.2.1"
1440214412
resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73"

0 commit comments

Comments
 (0)