001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.console.bus.websocket;
018
019import java.net.*;
020import java.util.*;
021
022import org.apache.juneau.console.bus.*;
023import org.apache.juneau.rest.server.filter.*;
024
025import jakarta.websocket.*;
026import jakarta.websocket.server.*;
027
028/**
029 * The handshake guards of the bus's WebSocket endpoint (Origin, Host, fetch-site and capability checks).  Not for
030 * direct use: it is public only because the container calls it.  {@link BusWebSockets#register} installs it.
031 *
032 * <ul>
033 *    <li>{@link #checkOrigin(String)}: runs before any upgrade, and a refusal is the container's {@code 403}.  Under a
034 *       {@link LoopbackBoundary} or {@code allowedOrigins} it is an exact match.  Under a {@link CsrfBoundary} it can
035 *       only see the {@code Origin} value, so it refuses a missing value, {@code null}, and anything that is not a
036 *       well-formed http(s) origin; a well-formed cross-site value passes it and is refused by the next step.
037 *    <li>{@link #modifyHandshake}: under a {@link LoopbackBoundary}, Host against the boundary's authority; under a
038 *       {@link CsrfBoundary}, {@link CsrfBoundary#checkHandshake(String, HandshakeHosts, Map)} with the scheme of the
039 *       handshake's request URI and its headers (so
040 *       {@link CsrfBoundary.Builder#trustedForwarding trusted forwarding}, when set, sees the forwarded headers).
041 *       Over HTTP/2 (RFC 8441), where the handshake has no {@code Host}, {@code Sec-WebSocket-Key} or
042 *       {@code Upgrade}, the request URI's authority stands in for {@code Host} ({@link HandshakeHosts}).  Otherwise
043 *       {@code Sec-Fetch-Site} (when present) must be {@code same-origin}.  Last, the capability and principal.  A
044 *       failure is recorded in the per-connection user properties, and {@link BusWebSocketEndpoint#onOpen} closes at
045 *       once with {@code 4403} ({@code bus:refused}) or {@code 4401} ({@code bus:unknown-session}).
046 * </ul>
047 *
048 * <p>
049 * The session id is read from the request path only.  It is never logged.
050 *
051 * <p>
052 * The refusal and session are stored in the user-properties map of the handshake, which relies on the container giving
053 * each handshake its own copy of that map (WebSocket 2.1 and later; Jetty and Tomcat do).  A container that shares one
054 * map across handshakes would mix up concurrent connections.
055 *
056 * <p>
057 * With {@link org.apache.juneau.rest.server.filter.CsrfBoundary.Builder#trustedProxies(String...) trustedProxies} set, the handshake's peer is read from the
058 * Jetty user property {@code jakarta.websocket.endpoint.remoteAddress}.  Jakarta WebSocket has no standard peer
059 * address; a container that records none (Tomcat) leaves the peer unknown, and an unknown peer is not trusted, so a
060 * handshake that carries a forwarded host is refused there.  The property is believable only because Jetty writes it
061 * into the per-handshake copy of the user properties; a container that copied client input into it would break that.
062 *
063 * <p>
064 * Trusted forwarding selects a hop by counting entries from the right, which relies on the container returning the
065 * repeated lines of a forwarded header in the order they arrived on the wire.  Jetty and Tomcat both do.  A container
066 * that reorders them would select the wrong hop.
067 *
068 * <h5 class='section'>Example:</h5>
069 * <p class='bjava'>
070 *   <jc>// Do not construct this class; register the endpoint instead:</jc>
071 *   BusWebSockets.<jsm>register</jsm>(<jv>servletContext</jv>, JobsRest.<jsf>BUS</jsf>);
072 * </p>
073 *
074 * @since 10.0.0
075 */
076public final class BusWebSocketConfigurator extends ServerEndpointConfig.Configurator {
077
078   /** User-property key of the {@link BusSession} a good handshake resolved. */
079   static final String SESSION_PROPERTY = "juneau.bus.session";
080
081   /** User-property key of the {@link Refusal} a failed handshake recorded. */
082   static final String REFUSAL_PROPERTY = "juneau.bus.refusal";
083
084   /**
085    * User-property key under which Jetty records the TCP peer of a handshake.  Non-standard.  It is trusted only
086    * because Jetty writes it into the per-handshake copy of the user properties after the client's headers are read, so
087    * a client cannot supply it.
088    */
089   static final String PEER_PROPERTY = "jakarta.websocket.endpoint.remoteAddress";
090
091   /** A handshake failure that {@code onOpen} turns into a close frame. */
092   record Refusal(int code, String reason) {}
093
094   /** Bad capability, principal mismatch or wrong transport: the browser re-POSTs the session. */
095   static final Refusal UNKNOWN_SESSION = new Refusal(4401, "bus:unknown-session");
096
097   /** Bad Host, {@code Origin} or {@code Sec-Fetch-Site}: the browser fails the bridge. */
098   static final Refusal REFUSED = new Refusal(4403, "bus:refused");
099
100   private final ServerBus bus;
101   private final Set<String> origins;
102   private final LoopbackBoundary loopback;
103   private final CsrfBoundary csrf;
104
105   BusWebSocketConfigurator(ServerBus bus, Set<String> origins) {
106      this.bus = bus;
107      this.origins = Set.copyOf(origins);
108      var boundary = bus.policy().boundary();
109      this.loopback = boundary instanceof LoopbackBoundary lb ? lb : null;
110      this.csrf = boundary instanceof CsrfBoundary cb ? cb : null;
111   }
112
113   /**
114    * The exact Origin values the policy allows: a {@link LoopbackBoundary}'s origin, nothing for a
115    * {@link CsrfBoundary} (which matches each handshake against its own Host instead), else {@code allowedOrigins}.
116    */
117   static Set<String> allowedOrigins(BusPolicy policy) {
118      var boundary = policy.boundary();
119      if (boundary instanceof LoopbackBoundary lb)
120         return Set.of(lb.origin());
121      if (boundary instanceof CsrfBoundary)
122         return Set.of();
123      return Set.copyOf(policy.allowedOrigins());
124   }
125
126   @Override /* Configurator */
127   public boolean checkOrigin(String originHeaderValue) {
128      if (csrf != null)
129         return csrf.acceptsHandshakeOrigin(originHeaderValue);
130      return originHeaderValue != null && origins.contains(originHeaderValue);
131   }
132
133   @Override /* Configurator */
134   public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
135      // Jakarta WebSocket 2.1+ hands each handshake its own copy of the user properties; the removes are belt and braces.
136      var props = sec.getUserProperties();
137      props.remove(SESSION_PROPERTY);
138      props.remove(REFUSAL_PROPERTY);
139      var refusal = check(request, props);
140      if (refusal != null)
141         props.put(REFUSAL_PROPERTY, refusal);
142   }
143
144   @SuppressWarnings({
145      "java:S3776" // Handshake gate: boundary variants and session lookup read best as one sequential check
146   })
147   private Refusal check(HandshakeRequest request, Map<String,Object> props) {
148      var uri = request.getRequestURI();
149      if (csrf != null) {
150         var hosts = HandshakeHosts.of(request.getHeaders(), uri);
151         var peer = csrf.trustedProxies().isEmpty() ? null : peer(props);
152         var result = csrf.checkHandshake(uri == null ? null : uri.getScheme(), hosts, request.getHeaders(), peer);
153         if (! result.isAllowed())
154            return REFUSED;
155      } else {
156         if (loopback != null && ! loopback.acceptsHandshakeHost(HandshakeHosts.of(request.getHeaders(), uri), uri == null ? null : uri.getScheme()))
157            return REFUSED;
158         var site = header(request, "Sec-Fetch-Site");
159         if (site != null && ! "same-origin".equals(site))
160            return REFUSED;
161      }
162      var id = sessionId(uri);
163      if (id == null)
164         return UNKNOWN_SESSION;
165      var session = bus.session(id, request.getUserPrincipal()).filter(s -> "websocket".equals(s.transport()));
166      if (session.isEmpty())
167         return UNKNOWN_SESSION;
168      props.put(SESSION_PROPERTY, session.get());
169      return null;
170   }
171
172   /**
173    * The TCP peer Jetty records in the handshake's user properties, or {@code null} when there is none to trust.
174    *
175    * <p>
176    * Only a resolved {@link InetSocketAddress} counts, and only its address bytes are used: never a host name, so
177    * nothing is looked up.  Tomcat records nothing, so the peer is unknown there and is not trusted.
178    */
179   private static InetAddress peer(Map<String,Object> props) {
180      return props.get(PEER_PROPERTY) instanceof InetSocketAddress a && ! a.isUnresolved() ? a.getAddress() : null;
181   }
182
183   /** The last path segment after {@link BusWebSockets#PREFIX}, or {@code null} when the path is malformed. */
184   static String sessionId(URI uri) {
185      var path = uri == null ? null : uri.getRawPath();
186      var prefix = BusWebSockets.PREFIX + "/";
187      var i = path == null ? -1 : path.lastIndexOf(prefix);
188      if (i < 0)
189         return null;
190      var id = path.substring(i + prefix.length());
191      return id.isEmpty() || id.indexOf('/') >= 0 ? null : id;
192   }
193
194   private static String header(HandshakeRequest request, String name) {
195      var headers = request.getHeaders();
196      if (headers != null)
197         for (var e : headers.entrySet())
198            if (name.equalsIgnoreCase(e.getKey()) && e.getValue() != null && ! e.getValue().isEmpty())
199               return e.getValue().get(0);
200      return null;
201   }
202
203   @Override /* Configurator */
204   public <T> T getEndpointInstance(Class<T> endpointClass) throws InstantiationException {
205      if (endpointClass != BusWebSocketEndpoint.class)
206         throw new InstantiationException("BusWebSocketConfigurator: unexpected endpoint class " + endpointClass.getName());
207      return endpointClass.cast(new BusWebSocketEndpoint(bus));
208   }
209}