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}