/
SURL.java
658 lines (584 loc) · 21.2 KB
/
SURL.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
/* ========================================================================
* PlantUML : a free UML diagram generator
* ========================================================================
*
* (C) Copyright 2009-2023, Arnaud Roques
*
* Project Info: http://plantuml.com
*
* If you like this project or if you find it useful, you can support us at:
*
* http://plantuml.com/patreon (only 1$ per month!)
* http://plantuml.com/paypal
*
* This file is part of PlantUML.
*
* PlantUML is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* PlantUML distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
* License for more details.
*
* You should have received a copy of the GNU General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
* USA.
*
*
* Original Author: Arnaud Roques
*
*
*/
package net.sourceforge.plantuml.security;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.Proxy;
import java.net.URL;
import java.net.URLConnection;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.net.ssl.HttpsURLConnection;
import javax.swing.ImageIcon;
import net.sourceforge.plantuml.StringUtils;
import net.sourceforge.plantuml.security.authentication.SecurityAccessInterceptor;
import net.sourceforge.plantuml.security.authentication.SecurityAuthentication;
import net.sourceforge.plantuml.security.authentication.SecurityCredentials;
/**
* Secure replacement for java.net.URL.
* <p>
* This class should be used instead of java.net.URL.
* <p>
* This class does some control access and manages access-tokens via URL. If a
* URL contains a access-token, similar to a user prefix, SURL loads the
* authorization config for this user-token and passes the credentials to the
* host.
* <p>
* Example:<br/>
*
* <pre>
* SURL url = SURL.create ("https://jenkins-access@jenkins.mycompany.com/api/json")
* </pre>
*
* The {@code jenkins-access} will checked against the Security context access
* token configuration. If a configuration exists for this token name, the token
* will be removed from the URL and the credentials will be added to the
* headers. If the token is not found, the URL remains as it is and no separate
* authentication will be performed.
* <p>
* TODO: Some methods should be moved to a HttpClient implementation, because
* SURL is not the valid class to manage it. <br/>
* TODO: BAD_HOSTS implementation should be reviewed and moved to HttpClient
* implementation with a circuit-breaker. <br/>
* TODO: Token expiration with refresh should be implemented in future. <br/>
*/
public class SURL {
/**
* Indicates, that we have no authentication to access the URL.
*/
public static final String WITHOUT_AUTHENTICATION = SecurityUtils.NO_CREDENTIALS;
/**
* Regex to remove the UserInfo part from a URL.
*/
private static final Pattern PATTERN_USERINFO = Pattern.compile("(^https?://)([-_0-9a-zA-Z]+@)([^@]*)");
private static final ExecutorService EXE = Executors.newCachedThreadPool(new ThreadFactory() {
public Thread newThread(Runnable r) {
final Thread t = Executors.defaultThreadFactory().newThread(r);
t.setDaemon(true);
return t;
}
});
private static final Map<String, Long> BAD_HOSTS = new ConcurrentHashMap<String, Long>();
/**
* Internal URL, maybe cleaned from user-token.
*/
private final URL internal;
/**
* Assigned credentials to this URL.
*/
private final String securityIdentifier;
private SURL(URL url, String securityIdentifier) {
this.internal = Objects.requireNonNull(url);
this.securityIdentifier = Objects.requireNonNull(securityIdentifier);
}
/**
* Create a secure URL from a String.
* <p>
* The url must be http or https. Return null in case of error or if
* <code>url</code> is null
*
* @param url plain url starting by http:// or https//
* @return the secure URL or null
*/
public static SURL create(String url) {
if (url == null)
return null;
if (url.startsWith("http://") || url.startsWith("https://"))
try {
return create(new URL(url));
} catch (MalformedURLException e) {
e.printStackTrace();
}
return null;
}
/**
* Create a secure URL from a <code>java.net.URL</code> object.
* <p>
* It takes into account credentials.
*
* @param url
* @return the secure URL
* @throws MalformedURLException if <code>url</code> is null
*/
public static SURL create(URL url) throws MalformedURLException {
if (url == null)
throw new MalformedURLException("URL cannot be null");
final String credentialId = url.getUserInfo();
if (credentialId == null || credentialId.indexOf(':') > 0)
// No user info at all, or a user with password (This is a legacy BasicAuth
// access, and we bypass it):
return new SURL(url, WITHOUT_AUTHENTICATION);
else if (SecurityUtils.existsSecurityCredentials(credentialId))
// Given userInfo, but without a password. We try to find SecurityCredentials
return new SURL(removeUserInfo(url), credentialId);
else
return new SURL(url, WITHOUT_AUTHENTICATION);
}
/**
* Creates a URL without UserInfo part and without SecurityCredentials.
*
* @param url plain URL
* @return SURL without any user credential information.
* @throws MalformedURLException
*/
static SURL createWithoutUser(URL url) throws MalformedURLException {
return new SURL(removeUserInfo(url), WITHOUT_AUTHENTICATION);
}
/**
* Clears the bad hosts cache.
* <p>
* In some test cases (and maybe also needed for other functionality) the bad
* hosts cache must be cleared.<br/>
* E.g., in a test we check the failure on missing credentials and then a test
* with existing credentials. With a bad host cache the second test will fail,
* or we have unpredicted results.
*/
static void resetBadHosts() {
BAD_HOSTS.clear();
}
@Override
public String toString() {
return internal.toString();
}
/**
* Check SecurityProfile to see if this URL can be opened.
*/
private boolean isUrlOk() {
if (SecurityUtils.getSecurityProfile() == SecurityProfile.SANDBOX)
// In SANDBOX, we cannot read any URL
return false;
if (SecurityUtils.getSecurityProfile() == SecurityProfile.LEGACY)
return true;
if (SecurityUtils.getSecurityProfile() == SecurityProfile.UNSECURE)
// We are UNSECURE anyway
return true;
if (isInUrlAllowList())
return true;
if (SecurityUtils.getSecurityProfile() == SecurityProfile.INTERNET) {
if (forbiddenURL(cleanPath(internal.toString())))
return false;
final int port = internal.getPort();
// Using INTERNET profile, port 80 and 443 are ok
return port == 80 || port == 443 || port == -1;
}
return false;
}
private boolean forbiddenURL(String full) {
if (full.matches("^https?://[.0-9]+/.*"))
return true;
if (full.matches("^https?://[^.]+/.*"))
return true;
return false;
}
private boolean isInUrlAllowList() {
final String full = cleanPath(internal.toString());
for (String allow : getUrlAllowList())
if (full.startsWith(cleanPath(allow)))
return true;
return false;
}
private String cleanPath(String path) {
// Remove user information, because we don't like to store user/password or
// userTokens in allow-list
path = removeUserInfoFromUrlPath(path);
path = path.trim().toLowerCase(Locale.US);
// We simplify/normalize the url, removing default ports
path = path.replace(":80/", "");
path = path.replace(":443/", "");
return path;
}
private List<String> getUrlAllowList() {
final String env = SecurityUtils.getenv(SecurityUtils.ALLOWLIST_URL);
if (env == null)
return Collections.emptyList();
return Arrays.asList(StringUtils.eventuallyRemoveStartingAndEndingDoubleQuote(env).split(";"));
}
/**
* Reads from an endpoint (with configured credentials and proxy) the response
* as blob.
* <p>
* This method allows access to an endpoint, with a configured
* SecurityCredentials object. The credentials will load on the fly and
* authentication fetched from an authentication-manager. Caching of tokens is
* not supported.
* <p>
* authors: Alain Corbiere, Aljoscha Rittner
*
* @return data loaded data from endpoint
*/
public byte[] getBytes() {
if (isUrlOk() == false)
return null;
final SecurityCredentials credentials = SecurityUtils.loadSecurityCredentials(securityIdentifier);
final SecurityAuthentication authentication = SecurityUtils.getAuthenticationManager(credentials)
.create(credentials);
try {
final String host = internal.getHost();
final Long bad = BAD_HOSTS.get(host);
if (bad != null) {
if ((System.currentTimeMillis() - bad) < 1000L * 60)
return null;
BAD_HOSTS.remove(host);
}
try {
final Future<byte[]> result = EXE
.submit(requestWithGetAndResponse(internal, credentials.getProxy(), authentication, null));
final byte[] data = result.get(SecurityUtils.getSecurityProfile().getTimeout(), TimeUnit.MILLISECONDS);
if (data != null)
return data;
} catch (Exception e) {
System.err.println("issue " + host + " " + e);
}
BAD_HOSTS.put(host, System.currentTimeMillis());
return null;
} finally {
// clean up. We don't cache tokens, no expire handling. All time a re-request.
credentials.eraseCredentials();
authentication.eraseCredentials();
}
}
/**
* Reads from an endpoint with a given authentication and proxy the response as
* blob.
* <p>
* This method allows a parametrized access to an endpoint, without a configured
* SecurityCredentials object. This is useful to access internally identity
* providers (IDP), or authorization servers (to request access tokens).
* <p>
* This method don't use the "bad-host" functionality, because the access to
* infrastructure services should not be obfuscated by some internal management.
* <p>
* <strong>Please don't use this method directly from DSL scripts.</strong>
*
* @param authentication authentication object data. Caller is responsible to
* erase credentials
* @param proxy proxy configuration
* @param headers additional headers, if needed
* @return loaded data from endpoint
*/
private byte[] getBytes(Proxy proxy, SecurityAuthentication authentication, Map<String, Object> headers) {
if (isUrlOk() == false)
return null;
final Future<byte[]> result = EXE.submit(requestWithGetAndResponse(internal, proxy, authentication, headers));
try {
return result.get(SecurityUtils.getSecurityProfile().getTimeout(), TimeUnit.MILLISECONDS);
} catch (Exception e) {
System.err.println("SURL response issue to " + internal.getHost() + " " + e);
return null;
}
}
/**
* Post to an endpoint with a given authentication and proxy the response as
* blob.
* <p>
* This method allows a parametrized access to an endpoint, without a configured
* SecurityCredentials object. This is useful to access internally identity
* providers (IDP), or authorization servers (to request access tokens).
* <p>
* This method don't use the "bad-host" functionality, because the access to
* infrastructure services should not be obfuscated by some internal management.
* <p>
* <strong>Please don't use this method directly from DSL scripts.</strong>
*
* @param authentication authentication object data. Caller is responsible to
* erase credentials
* @param proxy proxy configuration
* @param data content to post
* @param headers headers, if needed
* @return loaded data from endpoint
*/
public byte[] getBytesOnPost(Proxy proxy, SecurityAuthentication authentication, String data,
Map<String, Object> headers) {
if (isUrlOk() == false)
return null;
final Future<byte[]> result = EXE
.submit(requestWithPostAndResponse(internal, proxy, authentication, data, headers));
try {
return result.get(SecurityUtils.getSecurityProfile().getTimeout(), TimeUnit.MILLISECONDS);
} catch (Exception e) {
System.err.println("SURL response issue to " + internal.getHost() + " " + e);
return null;
}
}
/**
* Creates a GET request and response handler
*
* @param url URL to request
* @param proxy proxy to apply
* @param authentication the authentication to use
* @param headers additional headers, if needed
* @return the callable handler.
*/
private static Callable<byte[]> requestWithGetAndResponse(final URL url, final Proxy proxy,
final SecurityAuthentication authentication, final Map<String, Object> headers) {
return new Callable<byte[]>() {
private HttpURLConnection openConnection(final URL url) throws IOException {
// Add proxy, if passed throw parameters
final URLConnection connection = proxy == null ? url.openConnection() : url.openConnection(proxy);
if (connection == null)
return null;
final HttpURLConnection http = (HttpURLConnection) connection;
applyEndpointAccessAuthentication(http, authentication);
applyAdditionalHeaders(http, headers);
return http;
}
public byte[] call() throws IOException {
HttpURLConnection http = openConnection(url);
final int responseCode = http.getResponseCode();
if (responseCode == HttpURLConnection.HTTP_MOVED_TEMP
|| responseCode == HttpURLConnection.HTTP_MOVED_PERM) {
final String newUrl = http.getHeaderField("Location");
http = openConnection(new URL(newUrl));
}
return retrieveResponseAsBytes(http);
}
};
}
/**
* Creates a POST request and response handler with a simple String content. The
* content will be identified as form or JSON data. The charset encoding can be
* set by header parameters or will be set to UTF-8. The method to some fancy
* logic to simplify it for the user.
*
* @param url URL to request via POST method
* @param proxy proxy to apply
* @param authentication the authentication to use
* @param headers additional headers, if needed
* @return the callable handler.
*/
private static Callable<byte[]> requestWithPostAndResponse(final URL url, final Proxy proxy,
final SecurityAuthentication authentication, final String data, final Map<String, Object> headers) {
return new Callable<byte[]>() {
public byte[] call() throws IOException {
// Add proxy, if passed throw parameters
final URLConnection connection = proxy == null ? url.openConnection() : url.openConnection(proxy);
if (connection == null)
return null;
final boolean withContent = StringUtils.isNotEmpty(data);
final HttpURLConnection http = (HttpURLConnection) connection;
http.setRequestMethod("POST");
if (withContent)
http.setDoOutput(true);
applyEndpointAccessAuthentication(http, authentication);
applyAdditionalHeaders(http, headers);
final Charset charSet = extractCharset(http.getRequestProperty("Content-Type"));
if (withContent)
sendRequestAsBytes(http, data.getBytes(charSet != null ? charSet : StandardCharsets.UTF_8));
return retrieveResponseAsBytes(http);
}
};
}
private static Charset extractCharset(String contentType) {
if (StringUtils.isEmpty(contentType))
return null;
final Matcher matcher = Pattern.compile("(?i)\\bcharset=\\s*\"?([^\\s;\"]*)").matcher(contentType);
if (matcher.find())
try {
return Charset.forName(matcher.group(1));
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* Loads a response from an endpoint as a byte[] array.
*
* @param connection the URL connection
* @return the loaded byte arrays
* @throws IOException an exception, if the connection cannot establish or the
* download was broken
*/
private static byte[] retrieveResponseAsBytes(HttpURLConnection connection) throws IOException {
final int responseCode = connection.getResponseCode();
if (responseCode < HttpURLConnection.HTTP_BAD_REQUEST) {
try (InputStream input = connection.getInputStream()) {
return retrieveData(input);
}
} else {
try (InputStream error = connection.getErrorStream()) {
final byte[] bytes = retrieveData(error);
throw new IOException(
"HTTP error " + responseCode + " with " + new String(bytes, StandardCharsets.UTF_8));
}
}
}
/**
* Reads data in a byte[] array.
*
* @param input input stream
* @return byte data
* @throws IOException if something went wrong
*/
private static byte[] retrieveData(InputStream input) throws IOException {
final ByteArrayOutputStream out = new ByteArrayOutputStream();
final byte[] buffer = new byte[1024];
int read;
while ((read = input.read(buffer)) > 0) {
out.write(buffer, 0, read);
}
out.close();
return out.toByteArray();
}
/**
* Sends a request content payload to an endpoint.
*
* @param connection HTTP connection
* @param data data as byte array
* @throws IOException if something went wrong
*/
private static void sendRequestAsBytes(HttpURLConnection connection, byte[] data) throws IOException {
connection.setFixedLengthStreamingMode(data.length);
try (OutputStream os = connection.getOutputStream()) {
os.write(data);
}
}
public InputStream openStream() {
if (isUrlOk()) {
final byte[] data = getBytes();
if (data != null)
return new ByteArrayInputStream(data);
}
return null;
}
public BufferedImage readRasterImageFromURL() {
if (isUrlOk())
try {
final byte[] bytes = getBytes();
if (bytes == null || bytes.length == 0)
return null;
final ImageIcon tmp = new ImageIcon(bytes);
return SecurityUtils.readRasterImage(tmp);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* Informs, if SecurityCredentials are configured for this connection.
*
* @return true, if credentials will be used for a connection
*/
public boolean isAuthorizationConfigured() {
return WITHOUT_AUTHENTICATION.equals(securityIdentifier) == false;
}
/**
* Applies the given authentication data to the http connection.
*
* @param http HTTP URL connection (must be an encrypted https-TLS/SSL
* connection, or http must be activated with a property)
* @param authentication the data to request the access
* @see SecurityUtils#getAccessInterceptor(SecurityAuthentication)
* @see SecurityUtils#isNonSSLAuthenticationAllowed()
*/
private static void applyEndpointAccessAuthentication(URLConnection http, SecurityAuthentication authentication) {
if (authentication.isPublic())
// Shortcut: No need to apply authentication.
return;
if (http instanceof HttpsURLConnection || SecurityUtils.isNonSSLAuthenticationAllowed()) {
SecurityAccessInterceptor accessInterceptor = SecurityUtils.getAccessInterceptor(authentication);
accessInterceptor.apply(authentication, http);
} else {
// We cannot allow applying secret tokens on plain connections. Everyone can
// read the data.
throw new IllegalStateException(
"The transport of authentication data over an unencrypted http connection is not allowed");
}
}
/**
* Set the headers for a URL connection
*
* @param headers map Keys with values (can be String or list of String)
*/
private static void applyAdditionalHeaders(URLConnection http, Map<String, Object> headers) {
if (headers == null || headers.isEmpty())
return;
for (Map.Entry<String, Object> header : headers.entrySet()) {
final Object value = header.getValue();
if (value instanceof String)
http.setRequestProperty(header.getKey(), (String) value);
else if (value instanceof List)
for (Object item : (List<?>) value)
if (item != null)
http.addRequestProperty(header.getKey(), item.toString());
}
}
/**
* Removes the userInfo part from the URL, because we want to use the
* SecurityCredentials instead.
*
* @param url URL with UserInfo part
* @return url without UserInfo part
* @throws MalformedURLException
*/
private static URL removeUserInfo(URL url) throws MalformedURLException {
return new URL(removeUserInfoFromUrlPath(url.toExternalForm()));
}
/**
* Removes the userInfo part from the URL, because we want to use the
* SecurityCredentials instead.
*
* @param url URL with UserInfo part
* @return url without UserInfo part
*/
private static String removeUserInfoFromUrlPath(String url) {
// Simple solution:
final Matcher matcher = PATTERN_USERINFO.matcher(url);
if (matcher.find())
return matcher.replaceFirst("$1$3");
return url;
}
}