built-in web server — forbid untrusted access
authorVladimir Krivosheev <vladimir.krivosheev@jetbrains.com>
Fri, 22 Apr 2016 12:53:05 +0000 (14:53 +0200)
committerVladimir Krivosheev <vladimir.krivosheev@jetbrains.com>
Fri, 22 Apr 2016 13:17:33 +0000 (15:17 +0200)
platform/built-in-server/src/org/jetbrains/builtInWebServer/BuiltInWebBrowserUrlProvider.java
platform/built-in-server/src/org/jetbrains/builtInWebServer/BuiltInWebServer.kt
platform/platform-impl/src/com/intellij/ide/impl/ProjectUtil.java
platform/platform-impl/src/com/intellij/util/Urls.java

index 1fa74d36be451589919320a42820f87e0e0e140a..1f5b2bffe4bf79acd30b9ad4c2024f7c75c0cb2e 100644 (file)
@@ -53,19 +53,20 @@ public class BuiltInWebBrowserUrlProvider extends WebBrowserUrlProvider implemen
     String path = info.getPath();
 
     String authority = currentAuthority == null ? "localhost:" + effectiveBuiltInServerPort : currentAuthority;
-    List<Url> urls = new SmartList<>(Urls.newHttpUrl(authority, '/' + project.getName() + '/' + path));
+    String query = "?" + BuiltInWebServerKt.TOKEN_PARAM_NAME + "=" + BuiltInWebServerKt.acquireToken();
+    List<Url> urls = new SmartList<>(Urls.newHttpUrl(authority, '/' + project.getName() + '/' + path, query));
 
     String path2 = info.getRootLessPathIfPossible();
     if (path2 != null) {
-      urls.add(Urls.newHttpUrl(authority, '/' + project.getName() + '/' + path2));
+      urls.add(Urls.newHttpUrl(authority, '/' + project.getName() + '/' + path2, query));
     }
 
     int defaultPort = BuiltInServerManager.getInstance().getPort();
     if (currentAuthority == null && defaultPort != effectiveBuiltInServerPort) {
       String defaultAuthority = "localhost:" + defaultPort;
-      urls.add(Urls.newHttpUrl(defaultAuthority, '/' + project.getName() + '/' + path));
+      urls.add(Urls.newHttpUrl(defaultAuthority, '/' + project.getName() + '/' + path, query));
       if (path2 != null) {
-        urls.add(Urls.newHttpUrl(defaultAuthority, '/' + project.getName() + '/' + path2));
+        urls.add(Urls.newHttpUrl(defaultAuthority, '/' + project.getName() + '/' + path2, query));
       }
     }
 
index 2dd2b3f14948bbf6c5f0b0b59247cb8948bdcca7..d95e84877fe171397aac166ea0253b4b4344fbe3 100644 (file)
  */
 package org.jetbrains.builtInWebServer
 
+import com.google.common.cache.CacheBuilder
 import com.google.common.net.InetAddresses
+import com.intellij.ide.impl.ProjectUtil
+import com.intellij.openapi.application.ApplicationNamesInfo
+import com.intellij.openapi.application.PathManager
 import com.intellij.openapi.diagnostic.Logger
 import com.intellij.openapi.diagnostic.catchAndLog
 import com.intellij.openapi.project.Project
 import com.intellij.openapi.project.ProjectManager
+import com.intellij.openapi.ui.Messages
 import com.intellij.openapi.util.SystemInfoRt
 import com.intellij.openapi.util.io.FileUtil
 import com.intellij.openapi.util.io.FileUtilRt
 import com.intellij.openapi.util.io.endsWithName
+import com.intellij.openapi.util.text.StringUtil
 import com.intellij.openapi.vfs.VirtualFile
-import com.intellij.util.UriUtil
 import com.intellij.util.directoryStreamIfExists
 import com.intellij.util.io.URLUtil
 import com.intellij.util.isDirectory
 import com.intellij.util.net.NetUtils
+import io.netty.channel.Channel
 import io.netty.channel.ChannelHandlerContext
 import io.netty.handler.codec.http.*
+import io.netty.handler.codec.http.cookie.ClientCookieEncoder
+import io.netty.handler.codec.http.cookie.DefaultCookie
+import io.netty.handler.codec.http.cookie.ServerCookieDecoder
 import org.jetbrains.ide.HttpRequestHandler
-import org.jetbrains.io.host
-import org.jetbrains.io.isLocalOrigin
-import org.jetbrains.io.send
+import org.jetbrains.io.*
+import java.io.File
 import java.io.IOException
 import java.net.InetAddress
 import java.nio.file.Path
+import java.util.*
+import java.util.concurrent.TimeUnit
+import javax.swing.SwingUtilities
 
 internal val LOG = Logger.getInstance(BuiltInWebServer::class.java)
 
@@ -73,12 +84,52 @@ class BuiltInWebServer : HttpRequestHandler() {
     else {
       projectName = host
     }
-    return doProcess(request, context, projectName)
+    return doProcess(urlDecoder, request, context, projectName)
   }
 }
 
-private fun doProcess(request: FullHttpRequest, context: ChannelHandlerContext, projectNameAsHost: String?): Boolean {
-  val decodedPath = URLUtil.unescapePercentSequences(UriUtil.trimParameters(request.uri()))
+const val TOKEN_PARAM_NAME = "__ij-st"
+
+private val STANDARD_COOKIE by lazy {
+  val productName = ApplicationNamesInfo.getInstance().lowercaseProductName
+  val configPath = PathManager.getConfigPath()
+  val cookieName = productName + "-" + Integer.toHexString(configPath.hashCode())
+  val file = File(configPath, cookieName)
+  var token: String? = null
+  if (file.exists()) {
+    try {
+      token = UUID.fromString(FileUtil.loadFile(file)).toString()
+    }
+    catch (e: Exception) {
+      LOG.warn(e)
+    }
+  }
+  if (token == null) {
+    token = UUID.randomUUID().toString()
+    FileUtil.writeToFile(file, token!!)
+  }
+
+  val cookie = DefaultCookie(cookieName, token!!)
+  cookie.isHttpOnly = true
+  cookie.setMaxAge(TimeUnit.DAYS.toMillis(365 * 10))
+  cookie.setPath("/")
+  cookie
+}
+
+// expire after access because we reuse tokens
+private val tokens = CacheBuilder.newBuilder().expireAfterAccess(1, TimeUnit.MINUTES).build<String, Boolean>()
+
+internal fun acquireToken(): String {
+  var token = tokens.asMap().keys.firstOrNull()
+  if (token == null) {
+    token = UUID.randomUUID().toString()
+    tokens.put(token, java.lang.Boolean.TRUE)
+  }
+  return token
+}
+
+private fun doProcess(urlDecoder: QueryStringDecoder, request: FullHttpRequest, context: ChannelHandlerContext, projectNameAsHost: String?): Boolean {
+  val decodedPath = URLUtil.unescapePercentSequences(urlDecoder.path())
   var offset: Int
   var isEmptyPath: Boolean
   val isCustomHost = projectNameAsHost != null
@@ -139,8 +190,11 @@ private fun doProcess(request: FullHttpRequest, context: ChannelHandlerContext,
 
   val path = toIdeaPath(decodedPath, offset)
   if (path == null) {
-    LOG.warn("$decodedPath is not valid")
-    HttpResponseStatus.NOT_FOUND.send(context.channel(), request)
+    HttpResponseStatus.BAD_REQUEST.orInSafeMode(HttpResponseStatus.NOT_FOUND).send(context.channel(), request)
+    return true
+  }
+
+  if (!validateToken(request, context.channel(), urlDecoder)) {
     return true
   }
 
@@ -154,6 +208,42 @@ private fun doProcess(request: FullHttpRequest, context: ChannelHandlerContext,
   return false
 }
 
+private fun validateToken(request: HttpRequest, channel: Channel, urlDecoder: QueryStringDecoder): Boolean {
+  val cookieString = request.headers().get(HttpHeaderNames.COOKIE)
+  if (cookieString != null) {
+    val cookies = ServerCookieDecoder.STRICT.decode(cookieString)
+    for (cookie in cookies) {
+      if (cookie.name() == STANDARD_COOKIE.name()) {
+        if (cookie.value() == STANDARD_COOKIE.value()) {
+          return true
+        }
+        break
+      }
+    }
+  }
+
+  val token = urlDecoder.parameters().get(TOKEN_PARAM_NAME)?.firstOrNull()
+  val url = "${channel.uriScheme}://${request.host!!}${urlDecoder.path()}"
+  if (token != null && tokens.getIfPresent(token) != null) {
+    tokens.invalidate(token)
+    // we redirect because it is not easy to change and maintain all places where we send response
+    val response = HttpResponseStatus.TEMPORARY_REDIRECT.response()
+    response.headers().add(HttpHeaderNames.LOCATION, url)
+    response.headers().set(HttpHeaderNames.SET_COOKIE, ClientCookieEncoder.STRICT.encode(STANDARD_COOKIE))
+    response.send(channel, request)
+    return true
+  }
+
+  SwingUtilities.invokeAndWait {
+    ProjectUtil.focusProjectWindow(null, true)
+    Messages.showMessageDialog(ProjectUtil.getActiveFrameOrWelcomeScreen(), "Page '" + StringUtil.trimMiddle(url, 50) + "' requested without authorization, " +
+        "\nplease <a href='" + url + "?" + acquireToken() + "'>open this link</a> to trust it.", "", Messages.getWarningIcon())
+  }
+
+  HttpResponseStatus.UNAUTHORIZED.orInSafeMode(HttpResponseStatus.NOT_FOUND).send(channel, request)
+  return false
+}
+
 private fun toIdeaPath(decodedPath: String, offset: Int): String? {
   // must be absolute path (relative to DOCUMENT_ROOT, i.e. scheme://authority/) to properly canonicalize
   val path = decodedPath.substring(offset)
index 2a58f4691e267040417f5d8b8a4ac56bbdc91baf..4340318b9f69a33866fbd6302a8a92e61a4b30a9 100644 (file)
@@ -220,7 +220,7 @@ public class ProjectUtil {
     return answer == Messages.YES;
   }
 
-  private static Window getActiveFrameOrWelcomeScreen() {
+  public static Window getActiveFrameOrWelcomeScreen() {
     Window window = KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusedWindow();
     if (window != null)  return window;
 
index 5ae9a7c7117cebbf2739d2a3e96ac263deddfc4e..c487ab38c41e8c571e6476af136cc6e03cd853a1 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright 2000-2015 JetBrains s.r.o.
+ * Copyright 2000-2016 JetBrains s.r.o.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -72,6 +72,11 @@ public final class Urls {
     return newUrl("http", authority, path);
   }
 
+  @NotNull
+  public static Url newHttpUrl(@NotNull String authority, @Nullable String path, @Nullable String parameters) {
+    return new UrlImpl("http", authority, path, parameters);
+  }
+
   @NotNull
   public static Url newUrl(@NotNull String scheme, @NotNull String authority, @Nullable String path) {
     return new UrlImpl(scheme, authority, path);