Skip to content

Commit 5d02f7a

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 5d02f7a

File tree

8 files changed

+142
-47
lines changed

8 files changed

+142
-47
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/CreateWorkspace.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import Modal from "../components/Modal";
1212
import { getGitpodService, gitpodHostUrl } from "../service/service";
1313
import { UserContext } from "../user-context";
1414
import { StartPage, StartPhase, StartWorkspaceError } from "./StartPage";
15-
import StartWorkspace from "./StartWorkspace";
15+
import StartWorkspace, { parseProps } from "./StartWorkspace";
1616
import { openAuthorizeWindow } from "../provider-utils";
1717
import { SelectAccountPayload } from "@gitpod/gitpod-protocol/lib/auth";
1818
import { SelectAccountModal } from "../settings/SelectAccountModal";
@@ -154,7 +154,7 @@ export default class CreateWorkspace extends React.Component<CreateWorkspaceProp
154154

155155
const result = this.state?.result;
156156
if (result?.createdWorkspaceId) {
157-
return <StartWorkspace workspaceId={result.createdWorkspaceId} />;
157+
return <StartWorkspace {...parseProps(result?.createdWorkspaceId, window.location.search)} />;
158158
}
159159

160160
else if (result?.existingWorkspaces) {

components/dashboard/src/start/StartWorkspace.tsx

Lines changed: 122 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,54 @@ 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 runsInIFrame = window.top !== window.self;
36+
return {
37+
workspaceId,
38+
runsInIFrame: window.top !== window.self,
39+
// Either:
40+
// - 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:
41+
// - this is a (very) old tab and the workspace already timed out
42+
// - due to a start error our workspace terminated very quickly between:
43+
// a) us being redirected to that IDEUrl (based on the first ws-manager update) and
44+
// b) our requests being validated by ws-proxy
45+
// - runsInIFrame (IDE case):
46+
// - we assume the workspace has already been started for us
47+
// - we don't know it's instanceId
48+
dontAutostart: params.notFound || runsInIFrame,
49+
}
50+
}
51+
52+
function parseParameters(search?: string): { notFound?: boolean } {
53+
try {
54+
if (search === undefined) {
55+
return {};
56+
}
57+
const params = queryString.parse(search, {parseBooleans: true});
58+
const notFound = !!(params && params["not_found"]);
59+
return {
60+
notFound,
61+
};
62+
} catch (err) {
63+
console.error("/start: error parsing search params", err);
64+
return {};
65+
}
2566
}
2667

2768
export interface StartWorkspaceState {
69+
/**
70+
* This is set to the istanceId we started (think we started on).
71+
* We only receive updates for this particular instance, or none if not set.
72+
*/
2873
startedInstanceId?: string;
2974
workspaceInstance?: WorkspaceInstance;
3075
workspace?: Workspace;
@@ -34,8 +79,8 @@ export interface StartWorkspaceState {
3479
link: string
3580
label: string
3681
clientID?: string
37-
}
38-
ideOptions?: IDEOptions
82+
};
83+
ideOptions?: IDEOptions;
3984
}
4085

4186
export default class StartWorkspace extends React.Component<StartWorkspaceProps, StartWorkspaceState> {
@@ -47,7 +92,7 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
4792

4893
private readonly toDispose = new DisposableCollection();
4994
componentWillMount() {
50-
if (this.runsInIFrame()) {
95+
if (this.props.runsInIFrame) {
5196
window.parent.postMessage({ type: '$setSessionId', sessionId }, '*');
5297
const setStateEventListener = (event: MessageEvent) => {
5398
if (event.data.type === 'setState' && 'state' in event.data && typeof event.data['state'] === 'object') {
@@ -75,8 +120,15 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
75120
this.setState({ error });
76121
}
77122

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

82134
componentWillUnmount() {
@@ -130,14 +182,13 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
130182
}
131183
console.log("/start: started workspace instance: " + result.instanceID);
132184
// redirect to workspaceURL if we are not yet running in an iframe
133-
if (!this.runsInIFrame() && result.workspaceURL) {
185+
if (!this.props.runsInIFrame && result.workspaceURL) {
134186
this.redirectTo(result.workspaceURL);
135187
return;
136188
}
137-
this.setState({ startedInstanceId: result.instanceID });
138-
// Explicitly query state to guarantee we get at least one update
189+
// Start listening too instance updates - and explicitly query state once to guarantee we get at least one update
139190
// (needed for already started workspaces, and not hanging in 'Starting ...' for too long)
140-
this.fetchWorkspaceInfo();
191+
this.fetchWorkspaceInfo(result.instanceID);
141192
} catch (error) {
142193
console.error(error);
143194
if (typeof error === 'string') {
@@ -180,15 +231,28 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
180231
}
181232
}
182233

183-
async fetchWorkspaceInfo() {
234+
/**
235+
* Fetches initial WorkspaceInfo from the server. If there is a WorkspaceInstance for workspaceId, we feed it
236+
* into "onInstanceUpdate" and start accepting further updates.
237+
*
238+
* @param startedInstanceId The instanceId we want to listen on
239+
*/
240+
async fetchWorkspaceInfo(startedInstanceId: string | undefined) {
241+
// this ensures we're receiving updates for this instance
242+
if (startedInstanceId) {
243+
this.setState({ startedInstanceId });
244+
}
245+
184246
const { workspaceId } = this.props;
185247
try {
186248
const info = await getGitpodService().server.getWorkspace(workspaceId);
187249
if (info.latestInstance) {
188-
this.setState({
189-
workspace: info.workspace
190-
});
191-
this.onInstanceUpdate(info.latestInstance);
250+
const instance = info.latestInstance;
251+
this.setState((s) => ({
252+
workspace: info.workspace,
253+
startedInstanceId: s.startedInstanceId || instance.id, // note: here's a potential mismatch between startedInstanceId and instance.id. TODO(gpl) How to handle this?
254+
}));
255+
this.onInstanceUpdate(instance);
192256
}
193257
} catch (error) {
194258
console.error(error);
@@ -197,20 +261,35 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
197261
}
198262

199263
notifyDidOpenConnection() {
200-
this.fetchWorkspaceInfo();
264+
this.fetchWorkspaceInfo(undefined);
201265
}
202266

203267
async onInstanceUpdate(workspaceInstance: WorkspaceInstance) {
204-
const startedInstanceId = this.state?.startedInstanceId;
205-
if (workspaceInstance.workspaceId !== this.props.workspaceId || startedInstanceId !== workspaceInstance.id) {
268+
if (workspaceInstance.workspaceId !== this.props.workspaceId) {
206269
return;
207270
}
208271

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

211290
// Redirect to workspaceURL if we are not yet running in an iframe.
212291
// It happens this late if we were waiting for a docker build.
213-
if (!this.runsInIFrame() && workspaceInstance.ideUrl) {
292+
if (!this.props.runsInIFrame && workspaceInstance.ideUrl && !this.props.dontAutostart) {
214293
this.redirectTo(workspaceInstance.ideUrl);
215294
return;
216295
}
@@ -255,22 +334,18 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
255334
}
256335

257336
redirectTo(url: string) {
258-
if (this.runsInIFrame()) {
337+
if (this.props.runsInIFrame) {
259338
window.parent.postMessage({ type: 'relocate', url }, '*');
260339
} else {
261340
window.location.href = url;
262341
}
263342
}
264343

265-
runsInIFrame() {
266-
return window.top !== window.self;
267-
}
268-
269344
render() {
270345
const { error } = this.state;
271346
const isHeadless = this.state.workspace?.type !== 'regular';
272347
const isPrebuilt = WithPrebuild.is(this.state.workspace?.context);
273-
let phase = StartPhase.Preparing;
348+
let phase: StartPhase | undefined = StartPhase.Preparing;
274349
let title = undefined;
275350
let statusMessage = !!error ? undefined : <p className="text-base text-gray-400">Preparing workspace …</p>;
276351
const contextURL = ContextURL.getNormalizedURL(this.state.workspace)?.toString();
@@ -317,7 +392,29 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
317392
}
318393
if (!this.state.desktopIde) {
319394
phase = StartPhase.Running;
320-
statusMessage = <p className="text-base text-gray-400">Opening Workspace …</p>;
395+
396+
if (this.props.dontAutostart) {
397+
// hide the progress bar, as we're already running
398+
phase = undefined;
399+
title = 'Running';
400+
401+
// in case we dontAutostart the IDE we have to provide controls to do so
402+
statusMessage = <div>
403+
<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">
404+
<div className="rounded-full w-3 h-3 text-sm bg-green-500">&nbsp;</div>
405+
<div>
406+
<p className="text-gray-700 dark:text-gray-200 font-semibold w-56 truncate">{this.state.workspaceInstance.workspaceId}</p>
407+
<a target="_parent" href={contextURL}><p className="w-56 truncate hover:text-blue-600 dark:hover:text-blue-400" >{contextURL}</p></a>
408+
</div>
409+
</div>
410+
<div className="mt-10 justify-center flex space-x-2">
411+
<a target="_parent" href={gitpodHostUrl.asDashboard().toString()}><button className="secondary">Go to Dashboard</button></a>
412+
<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>
413+
</div>
414+
</div>;
415+
} else {
416+
statusMessage = <p className="text-base text-gray-400">Opening Workspace …</p>;
417+
}
321418
} else {
322419
phase = StartPhase.IdeReady;
323420
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)