add "Allow unsigned requests"
[idea/community.git] / platform / built-in-server / src / org / jetbrains / builtInWebServer / BuiltInWebServer.kt
1 /*
2  * Copyright 2000-2015 JetBrains s.r.o.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  * http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package org.jetbrains.builtInWebServer
17
18 import com.google.common.cache.CacheBuilder
19 import com.google.common.net.InetAddresses
20 import com.intellij.ide.impl.ProjectUtil
21 import com.intellij.ide.util.PropertiesComponent
22 import com.intellij.notification.NotificationType
23 import com.intellij.openapi.application.ApplicationNamesInfo
24 import com.intellij.openapi.application.PathManager
25 import com.intellij.openapi.diagnostic.Logger
26 import com.intellij.openapi.diagnostic.catchAndLog
27 import com.intellij.openapi.ide.CopyPasteManager
28 import com.intellij.openapi.project.Project
29 import com.intellij.openapi.project.ProjectManager
30 import com.intellij.openapi.ui.MessageDialogBuilder
31 import com.intellij.openapi.ui.Messages
32 import com.intellij.openapi.util.SystemInfoRt
33 import com.intellij.openapi.util.io.FileUtil
34 import com.intellij.openapi.util.io.FileUtilRt
35 import com.intellij.openapi.util.io.endsWithName
36 import com.intellij.openapi.util.registry.Registry
37 import com.intellij.openapi.util.text.StringUtil
38 import com.intellij.openapi.vfs.VirtualFile
39 import com.intellij.util.*
40 import com.intellij.util.io.URLUtil
41 import com.intellij.util.net.NetUtils
42 import io.netty.channel.Channel
43 import io.netty.channel.ChannelHandlerContext
44 import io.netty.handler.codec.http.*
45 import io.netty.handler.codec.http.cookie.DefaultCookie
46 import io.netty.handler.codec.http.cookie.ServerCookieDecoder
47 import io.netty.handler.codec.http.cookie.ServerCookieEncoder
48 import org.jetbrains.ide.BuiltInServerManagerImpl
49 import org.jetbrains.ide.HttpRequestHandler
50 import org.jetbrains.io.*
51 import org.jetbrains.notification.SingletonNotificationManager
52 import java.awt.datatransfer.StringSelection
53 import java.io.IOException
54 import java.math.BigInteger
55 import java.net.InetAddress
56 import java.nio.file.Files
57 import java.nio.file.Path
58 import java.nio.file.Paths
59 import java.nio.file.attribute.PosixFileAttributeView
60 import java.nio.file.attribute.PosixFilePermission
61 import java.security.SecureRandom
62 import java.util.*
63 import java.util.concurrent.TimeUnit
64 import javax.swing.SwingUtilities
65
66 internal val LOG = Logger.getInstance(BuiltInWebServer::class.java)
67
68 // name is duplicated in the ConfigImportHelper
69 private const val IDE_TOKEN_FILE = "user.web.token"
70
71 private val notificationManager by lazy {
72   SingletonNotificationManager(BuiltInServerManagerImpl.NOTIFICATION_GROUP.value, NotificationType.INFORMATION, null)
73 }
74
75 class BuiltInWebServer : HttpRequestHandler() {
76   override fun isAccessible(request: HttpRequest) = request.isLocalOrigin(onlyAnyOrLoopback = false, hostsOnly = true)
77
78   override fun isSupported(request: FullHttpRequest) = super.isSupported(request) || request.method() == HttpMethod.POST
79
80   override fun process(urlDecoder: QueryStringDecoder, request: FullHttpRequest, context: ChannelHandlerContext): Boolean {
81     var host = request.host
82     if (host.isNullOrEmpty()) {
83       return false
84     }
85
86     val portIndex = host!!.indexOf(':')
87     if (portIndex > 0) {
88       host = host.substring(0, portIndex)
89     }
90
91     val projectName: String?
92     val isIpv6 = host[0] == '[' && host.length > 2 && host[host.length - 1] == ']'
93     if (isIpv6) {
94       host = host.substring(1, host.length - 1)
95     }
96
97     if (isIpv6 || InetAddresses.isInetAddress(host) || isOwnHostName(host) || host.endsWith(".ngrok.io")) {
98       if (urlDecoder.path().length < 2) {
99         return false
100       }
101       projectName = null
102     }
103     else {
104       projectName = host
105     }
106     return doProcess(urlDecoder, request, context, projectName)
107   }
108 }
109
110 internal fun isActivatable() = Registry.`is`("ide.built.in.web.server.activatable", false)
111
112 internal const val TOKEN_PARAM_NAME = "_ijt"
113 const val TOKEN_HEADER_NAME = "x-ijt"
114
115 private val STANDARD_COOKIE by lazy {
116   val productName = ApplicationNamesInfo.getInstance().lowercaseProductName
117   val configPath = PathManager.getConfigPath()
118   val file = Paths.get(configPath, IDE_TOKEN_FILE)
119   var token: String? = null
120   if (file.exists()) {
121     try {
122       token = UUID.fromString(file.readText()).toString()
123     }
124     catch (e: Exception) {
125       LOG.warn(e)
126     }
127   }
128   if (token == null) {
129     token = UUID.randomUUID().toString()
130     file.write(token!!)
131     val view = Files.getFileAttributeView(file, PosixFileAttributeView::class.java)
132     if (view != null) {
133       try {
134         view.setPermissions(setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE))
135       }
136       catch (e: IOException) {
137         LOG.warn(e)
138       }
139     }
140   }
141
142   // explicit setting domain cookie on localhost doesn't work for chrome
143   // http://stackoverflow.com/questions/8134384/chrome-doesnt-create-cookie-for-domain-localhost-in-broken-https
144   val cookie = DefaultCookie(productName + "-" + Integer.toHexString(configPath.hashCode()), token!!)
145   cookie.isHttpOnly = true
146   cookie.setMaxAge(TimeUnit.DAYS.toSeconds(365 * 10))
147   cookie.setPath("/")
148   cookie
149 }
150
151 // expire after access because we reuse tokens
152 private val tokens = CacheBuilder.newBuilder().expireAfterAccess(1, TimeUnit.MINUTES).build<String, Boolean>()
153
154 fun acquireToken(): String {
155   var token = tokens.asMap().keys.firstOrNull()
156   if (token == null) {
157     token = TokenGenerator.generate()
158     tokens.put(token, java.lang.Boolean.TRUE)
159   }
160   return token
161 }
162
163 // http://stackoverflow.com/a/41156 - shorter than UUID, but secure
164 private object TokenGenerator {
165   private val random = SecureRandom()
166
167   fun generate(): String = BigInteger(130, random).toString(32)
168 }
169
170 private fun doProcess(urlDecoder: QueryStringDecoder, request: FullHttpRequest, context: ChannelHandlerContext, projectNameAsHost: String?): Boolean {
171   val decodedPath = URLUtil.unescapePercentSequences(urlDecoder.path())
172   var offset: Int
173   var isEmptyPath: Boolean
174   val isCustomHost = projectNameAsHost != null
175   var projectName: String
176   if (isCustomHost) {
177     projectName = projectNameAsHost!!
178     // host mapped to us
179     offset = 0
180     isEmptyPath = decodedPath.isEmpty()
181   }
182   else {
183     offset = decodedPath.indexOf('/', 1)
184     projectName = decodedPath.substring(1, if (offset == -1) decodedPath.length else offset)
185     isEmptyPath = offset == -1
186   }
187
188   var candidateByDirectoryName: Project? = null
189   val project = ProjectManager.getInstance().openProjects.firstOrNull(fun(project: Project): Boolean {
190     if (project.isDisposed) {
191       return false
192     }
193
194     val name = project.name
195     if (isCustomHost) {
196       // domain name is case-insensitive
197       if (projectName.equals(name, ignoreCase = true)) {
198         if (!SystemInfoRt.isFileSystemCaseSensitive) {
199           // may be passed path is not correct
200           projectName = name
201         }
202         return true
203       }
204     }
205     else {
206       // WEB-17839 Internal web server reports 404 when serving files from project with slashes in name
207       if (decodedPath.regionMatches(1, name, 0, name.length, !SystemInfoRt.isFileSystemCaseSensitive)) {
208         val isEmptyPathCandidate = decodedPath.length == (name.length + 1)
209         if (isEmptyPathCandidate || decodedPath[name.length + 1] == '/') {
210           projectName = name
211           offset = name.length + 1
212           isEmptyPath = isEmptyPathCandidate
213           return true
214         }
215       }
216     }
217
218     if (candidateByDirectoryName == null && compareNameAndProjectBasePath(projectName, project)) {
219       candidateByDirectoryName = project
220     }
221     return false
222   }) ?: candidateByDirectoryName ?: return false
223
224   if (isActivatable() && !PropertiesComponent.getInstance().getBoolean("ide.built.in.web.server.active")) {
225     notificationManager.notify("Built-in web server is deactivated, to activate, please use Open in Browser", null)
226     return false
227   }
228
229   if (isEmptyPath) {
230     // we must redirect "jsdebug" to "jsdebug/" as nginx does, otherwise browser will treat it as a file instead of a directory, so, relative path will not work
231     redirectToDirectory(request, context.channel(), projectName, null)
232     return true
233   }
234
235   val path = toIdeaPath(decodedPath, offset)
236   if (path == null) {
237     HttpResponseStatus.BAD_REQUEST.orInSafeMode(HttpResponseStatus.NOT_FOUND).send(context.channel(), request)
238     return true
239   }
240
241   for (pathHandler in WebServerPathHandler.EP_NAME.extensions) {
242     LOG.catchAndLog {
243       if (pathHandler.process(path, project, request, context, projectName, decodedPath, isCustomHost)) {
244         return true
245       }
246     }
247   }
248   return false
249 }
250
251 internal fun HttpRequest.isSignedRequest(): Boolean {
252   if (BuiltInServerOptions.getInstance().allowUnsignedRequests) {
253     return true
254   }
255
256   // we must check referrer - if html cached, browser will send request without query
257   val token = headers().get(TOKEN_HEADER_NAME)
258       ?: QueryStringDecoder(uri()).parameters().get(TOKEN_PARAM_NAME)?.firstOrNull()
259       ?: referrer?.let { QueryStringDecoder(it).parameters().get(TOKEN_PARAM_NAME)?.firstOrNull() }
260
261   // we don't invalidate token — allow to make subsequent requests using it (it is required for our javadoc DocumentationComponent)
262   return token != null && tokens.getIfPresent(token) != null
263 }
264
265 @JvmOverloads
266 internal fun validateToken(request: HttpRequest, channel: Channel, isSignedRequest: Boolean = request.isSignedRequest()): HttpHeaders? {
267   if (BuiltInServerOptions.getInstance().allowUnsignedRequests) {
268     return EmptyHttpHeaders.INSTANCE
269   }
270
271   request.headers().get(HttpHeaderNames.COOKIE)?.let {
272     for (cookie in ServerCookieDecoder.STRICT.decode(it)) {
273       if (cookie.name() == STANDARD_COOKIE.name()) {
274         if (cookie.value() == STANDARD_COOKIE.value()) {
275           return EmptyHttpHeaders.INSTANCE
276         }
277         break
278       }
279     }
280   }
281
282   if (isSignedRequest) {
283     return DefaultHttpHeaders().set(HttpHeaderNames.SET_COOKIE, ServerCookieEncoder.STRICT.encode(STANDARD_COOKIE) + "; SameSite=strict")
284   }
285
286   val urlDecoder = QueryStringDecoder(request.uri())
287   if (!urlDecoder.path().endsWith("/favicon.ico")) {
288     val url = "${channel.uriScheme}://${request.host!!}${urlDecoder.path()}"
289     SwingUtilities.invokeAndWait {
290       ProjectUtil.focusProjectWindow(null, true)
291
292       if (MessageDialogBuilder
293           .yesNo("", "Page '" + StringUtil.trimMiddle(url, 50) + "' requested without authorization, " +
294               "\nyou can copy URL and open it in browser to trust it.")
295           .icon(Messages.getWarningIcon())
296           .yesText("Copy authorization URL to clipboard")
297           .show() == Messages.YES) {
298         CopyPasteManager.getInstance().setContents(StringSelection(url + "?" + TOKEN_PARAM_NAME + "=" + acquireToken()))
299       }
300     }
301   }
302
303   HttpResponseStatus.UNAUTHORIZED.orInSafeMode(HttpResponseStatus.NOT_FOUND).send(channel, request)
304   return null
305 }
306
307 private fun toIdeaPath(decodedPath: String, offset: Int): String? {
308   // must be absolute path (relative to DOCUMENT_ROOT, i.e. scheme://authority/) to properly canonicalize
309   val path = decodedPath.substring(offset)
310   if (!path.startsWith('/')) {
311     return null
312   }
313   return FileUtil.toCanonicalPath(path, '/').substring(1)
314 }
315
316 fun compareNameAndProjectBasePath(projectName: String, project: Project): Boolean {
317   val basePath = project.basePath
318   return basePath != null && endsWithName(basePath, projectName)
319 }
320
321 fun findIndexFile(basedir: VirtualFile): VirtualFile? {
322   val children = basedir.children
323   if (children == null || children.isEmpty()) {
324     return null
325   }
326
327   for (indexNamePrefix in arrayOf("index.", "default.")) {
328     var index: VirtualFile? = null
329     val preferredName = indexNamePrefix + "html"
330     for (child in children) {
331       if (!child.isDirectory) {
332         val name = child.name
333         //noinspection IfStatementWithIdenticalBranches
334         if (name == preferredName) {
335           return child
336         }
337         else if (index == null && name.startsWith(indexNamePrefix)) {
338           index = child
339         }
340       }
341     }
342     if (index != null) {
343       return index
344     }
345   }
346   return null
347 }
348
349 fun findIndexFile(basedir: Path): Path? {
350   val children = basedir.directoryStreamIfExists({
351     val name = it.fileName.toString()
352     name.startsWith("index.") || name.startsWith("default.")
353   }) { it.toList() } ?: return null
354
355   for (indexNamePrefix in arrayOf("index.", "default.")) {
356     var index: Path? = null
357     val preferredName = "${indexNamePrefix}html"
358     for (child in children) {
359       if (!child.isDirectory()) {
360         val name = child.fileName.toString()
361         if (name == preferredName) {
362           return child
363         }
364         else if (index == null && name.startsWith(indexNamePrefix)) {
365           index = child
366         }
367       }
368     }
369     if (index != null) {
370       return index
371     }
372   }
373   return null
374 }
375
376 // is host loopback/any or network interface address (i.e. not custom domain)
377 // must be not used to check is host on local machine
378 internal fun isOwnHostName(host: String): Boolean {
379   if (NetUtils.isLocalhost(host)) {
380     return true
381   }
382
383   try {
384     val address = InetAddress.getByName(host)
385     if (host == address.hostAddress || host.equals(address.canonicalHostName, ignoreCase = true)) {
386       return true
387     }
388
389     val localHostName = InetAddress.getLocalHost().hostName
390     // WEB-8889
391     // develar.local is own host name: develar. equals to "develar.labs.intellij.net" (canonical host name)
392     return localHostName.equals(host, ignoreCase = true) || (host.endsWith(".local") && localHostName.regionMatches(0, host, 0, host.length - ".local".length, true))
393   }
394   catch (ignored: IOException) {
395     return false
396   }
397 }
398
399 internal fun canBeAccessedDirectly(path: String): Boolean {
400   for (fileHandler in WebServerFileHandler.EP_NAME.extensions) {
401     for (ext in fileHandler.pageFileExtensions) {
402       if (FileUtilRt.extensionEquals(path, ext)) {
403         return true
404       }
405     }
406   }
407   return false
408 }