Skip to content

Commit 2c30263

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 (either by supervisor or ws-proxy)
1 parent dd8b5b7 commit 2c30263

File tree

5 files changed

+114
-21
lines changed

5 files changed

+114
-21
lines changed

components/dashboard/src/App.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { Experiment } from './experiments';
2525
import { workspacesPathMain } from './workspaces/workspaces.routes';
2626
import { settingsPathAccount, settingsPathIntegrations, settingsPathMain, settingsPathNotifications, settingsPathPlans, settingsPathPreferences, settingsPathTeams, settingsPathTeamsJoin, settingsPathTeamsNew, settingsPathVariables } from './settings/settings.routes';
2727
import { projectsPathInstallGitHubApp, projectsPathMain, projectsPathMainWithParams, projectsPathNew } from './projects/projects.routes';
28+
import { StartWorkspaceParameters } from './start/StartWorkspace';
2829

2930
const Setup = React.lazy(() => import(/* webpackPrefetch: true */ './Setup'));
3031
const Workspaces = React.lazy(() => import(/* webpackPrefetch: true */ './workspaces/Workspaces'));
@@ -401,7 +402,8 @@ function App() {
401402
} else if (isCreation) {
402403
toRender = <CreateWorkspace contextUrl={hash} />;
403404
} else if (isWsStart) {
404-
toRender = <StartWorkspace workspaceId={hash} />;
405+
const params = StartWorkspaceParameters.parse(window.location.search);
406+
toRender = <StartWorkspace workspaceId={hash} parameters={params} />;
405407
} else if (/^(github|gitlab)\.com\/.+?/i.test(window.location.pathname)) {
406408
let url = new URL(window.location.href)
407409
url.hash = url.pathname

components/dashboard/src/start/StartWorkspace.tsx

Lines changed: 105 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,54 @@ const sessionId = v4();
2121
const WorkspaceLogs = React.lazy(() => import('../components/WorkspaceLogs'));
2222

2323
export interface StartWorkspaceProps {
24-
workspaceId: string;
24+
workspaceId: string,
25+
parameters?: StartWorkspaceParameters,
26+
}
27+
28+
export interface StartWorkspaceParameters {
29+
trigger?: StartWorkspaceTrigger,
30+
}
31+
32+
const StartWorkspaceTriggers = {
33+
/**
34+
* A workspace cluster's ws-proxy redirected the client to "/start/#<wsId>" because it could not find that workspace
35+
*/
36+
"redirect_from_ws_cluster": undefined,
37+
38+
/**
39+
* Supervisor redirect the client to "/start/#<wsId>" because it could
40+
*/
41+
"redirect_from_supervisor": undefined,
42+
};
43+
export type StartWorkspaceTrigger = keyof (typeof StartWorkspaceTriggers);
44+
45+
export namespace StartWorkspaceParameters {
46+
export function parse(search: string): StartWorkspaceParameters | undefined {
47+
try {
48+
const result: StartWorkspaceParameters = {};
49+
const params = new URLSearchParams(search);
50+
for (const [k, v] of params.entries()) {
51+
switch (k) {
52+
case "trigger":
53+
if (v in StartWorkspaceTriggers) {
54+
result.trigger = v as StartWorkspaceTrigger;
55+
}
56+
break;
57+
}
58+
}
59+
return result;
60+
} catch (err) {
61+
console.error("/start: error parsing search params", err);
62+
return undefined;
63+
}
64+
}
2565
}
2666

2767
export interface StartWorkspaceState {
68+
/**
69+
* This is set to the istanceId we started (think we started on).
70+
* We only receive updates for this particular instance, or none if not set.
71+
*/
2872
startedInstanceId?: string;
2973
workspaceInstance?: WorkspaceInstance;
3074
workspace?: Workspace;
@@ -34,8 +78,13 @@ export interface StartWorkspaceState {
3478
link: string
3579
label: string
3680
clientID?: string
37-
}
38-
ideOptions?: IDEOptions
81+
};
82+
ideOptions?: IDEOptions;
83+
showRestartModal?: boolean;
84+
/**
85+
* This flag is used to break the autostart-cycle explained in https://github.com/gitpod-io/gitpod/issues/8043
86+
*/
87+
dontAutostart?: boolean;
3988
}
4089

4190
export default class StartWorkspace extends React.Component<StartWorkspaceProps, StartWorkspaceState> {
@@ -75,8 +124,34 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
75124
this.setState({ error });
76125
}
77126

127+
// we're coming back from a workspace cluster/IDE URL where we expected a workspace to be running but it wasn't because either:
128+
// - this is a (very) old tab and the workspace already timed out
129+
// - due to a start error our workspace terminated very quickly between:
130+
// a) us being redirected to that IDEUrl (based on the first ws-manager update) and
131+
// b) our requests being validated by ws-proxy
132+
// we break this potentially DDOS cycle by showing a modal "Restart workspace?" instead of auto-restarting said workspace.
133+
const params = this.props.parameters;
134+
if (params?.trigger === "redirect_from_ws_cluster"
135+
|| params?.trigger === "redirect_from_supervisor") {
136+
this.setState({ dontAutostart: true });
137+
this.fetchWorkspaceInfo(undefined);
138+
return;
139+
}
140+
141+
// IDE case:
142+
// - we assume the workspace has already been started for us
143+
// - we don't know it's instanceId
144+
// we could also rely on "startWorkspace" to return us the "running" instance, but that opens the door for
145+
// self-DDOSing as we found in the past (cmp. https://github.com/gitpod-io/gitpod/issues/8043)
146+
if (this.runsInIFrame()) {
147+
// fetch current state display (incl. potential errors)
148+
this.setState({ dontAutostart: true });
149+
this.fetchWorkspaceInfo(undefined);
150+
return;
151+
}
152+
153+
// dashboard case (no previous errors): start workspace as quickly as possible
78154
this.startWorkspace();
79-
getGitpodService().server.getIDEOptions().then(ideOptions => this.setState({ ideOptions }))
80155
}
81156

82157
componentWillUnmount() {
@@ -134,10 +209,12 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
134209
this.redirectTo(result.workspaceURL);
135210
return;
136211
}
137-
this.setState({ startedInstanceId: result.instanceID });
138-
// Explicitly query state to guarantee we get at least one update
212+
// Start listening too instance updates - and explicitly query state once to guarantee we get at least one update
139213
// (needed for already started workspaces, and not hanging in 'Starting ...' for too long)
140-
this.fetchWorkspaceInfo();
214+
this.fetchWorkspaceInfo(result.instanceID);
215+
216+
// query IDE options so we can show them if necessary once the workspace is running
217+
getGitpodService().server.getIDEOptions().then(ideOptions => this.setState({ ideOptions }));
141218
} catch (error) {
142219
console.error(error);
143220
if (typeof error === 'string') {
@@ -180,15 +257,28 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
180257
}
181258
}
182259

183-
async fetchWorkspaceInfo() {
260+
/**
261+
* Fetches initial WorkspaceInfo from the server. If there is a WorkspaceInstance for workspaceId, we feed it
262+
* into "onInstanceUpdate" and start accepting further updates.
263+
*
264+
* @param startedInstanceId The instanceId we want to listen on
265+
*/
266+
async fetchWorkspaceInfo(startedInstanceId: string | undefined) {
267+
// this ensures we're receiving updates for this instance
268+
if (startedInstanceId) {
269+
this.setState({ startedInstanceId });
270+
}
271+
184272
const { workspaceId } = this.props;
185273
try {
186274
const info = await getGitpodService().server.getWorkspace(workspaceId);
187275
if (info.latestInstance) {
188-
this.setState({
189-
workspace: info.workspace
190-
});
191-
this.onInstanceUpdate(info.latestInstance);
276+
const instance = info.latestInstance;
277+
this.setState((s) => ({
278+
workspace: info.workspace,
279+
startedInstanceId: s.startedInstanceId || instance.id, // note: here's a potential mismatch between startedInstanceId and instance.id. TODO(gpl) How to handle this?
280+
}));
281+
this.onInstanceUpdate(instance);
192282
}
193283
} catch (error) {
194284
console.error(error);
@@ -197,7 +287,7 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
197287
}
198288

199289
notifyDidOpenConnection() {
200-
this.fetchWorkspaceInfo();
290+
this.fetchWorkspaceInfo(undefined);
201291
}
202292

203293
async onInstanceUpdate(workspaceInstance: WorkspaceInstance) {
@@ -210,7 +300,8 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
210300

211301
// Redirect to workspaceURL if we are not yet running in an iframe.
212302
// It happens this late if we were waiting for a docker build.
213-
if (!this.runsInIFrame() && workspaceInstance.ideUrl) {
303+
const dontRedirect = this.state?.dontAutostart;
304+
if (!this.runsInIFrame() && workspaceInstance.ideUrl && !dontRedirect) {
214305
this.redirectTo(workspaceInstance.ideUrl);
215306
return;
216307
}

components/supervisor/frontend/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ const toStop = new DisposableCollection();
153153
// reload the page if the workspace was restarted to ensure:
154154
// - graceful reconnection of IDEs
155155
// - new owner token is set
156-
window.location.href = startUrl.toString();
156+
window.location.href = startUrl.with({ search: "trigger=redirect_from_supervisor" }).toString();
157157
}
158158
}
159159
return loading.frame;

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/?trigger=redirect_from_ws_cluster#%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/?trigger=redirect_from_ws_cluster#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/?trigger=redirect_from_ws_cluster#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/?trigger=redirect_from_ws_cluster#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/?trigger=redirect_from_ws_cluster#blabla-smelt-9ba20cc1\">Found</a>.\n\n"),
436436
},
437437
},
438438
{

0 commit comments

Comments
 (0)