appium-webdriveragent 16.7.0 → 16.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [16.7.2](https://github.com/appium/WebDriverAgent/compare/v16.7.1...v16.7.2) (2026-08-24)
2
+
3
+ ### Bug Fixes
4
+
5
+ * harden FBHTTPServer/FBTCPSocket against races and protocol gaps ([#1224](https://github.com/appium/WebDriverAgent/issues/1224)) ([cf4bb2b](https://github.com/appium/WebDriverAgent/commit/cf4bb2b57b4d3a55ee4bbe8f860b00b0e7f5a326))
6
+
7
+ ## [16.7.1](https://github.com/appium/WebDriverAgent/compare/v16.7.0...v16.7.1) (2026-08-23)
8
+
9
+ ### Bug Fixes
10
+
11
+ * return W3C-compliant JSON error for unmatched routes ([#1223](https://github.com/appium/WebDriverAgent/issues/1223)) ([c951a91](https://github.com/appium/WebDriverAgent/commit/c951a91c8202d3c875db04dd633d37b409c80e43))
12
+
1
13
  ## [16.7.0](https://github.com/appium/WebDriverAgent/compare/v16.6.0...v16.7.0) (2026-08-22)
2
14
 
3
15
  ### Features
@@ -15,11 +15,11 @@
15
15
  <key>CFBundlePackageType</key>
16
16
  <string>FMWK</string>
17
17
  <key>CFBundleShortVersionString</key>
18
- <string>16.7.0</string>
18
+ <string>16.7.2</string>
19
19
  <key>CFBundleSignature</key>
20
20
  <string>????</string>
21
21
  <key>CFBundleVersion</key>
22
- <string>16.7.0</string>
22
+ <string>16.7.2</string>
23
23
  <key>NSPrincipalClass</key>
24
24
  <string/>
25
25
  </dict>
@@ -9,8 +9,9 @@
9
9
  // A minimal HTTP/1.1 server on top of FBTCPSocket (Network.framework-backed on every platform,
10
10
  // since watchOS forbids BSD sockets outright - see FBTCPSocket.h/.m).
11
11
  //
12
- // No chunked encoding, range requests, or pipelining - just request line + headers +
13
- // Content-Length body, and ":param" path matching.
12
+ // No range requests or request pipelining - just request line + headers + Content-Length body,
13
+ // and ":param" path matching. Any Transfer-Encoding is rejected outright (501) rather than
14
+ // silently mishandled, since no decoder is implemented.
14
15
 
15
16
  @import Foundation;
16
17
 
@@ -8,7 +8,9 @@
8
8
 
9
9
  #import "FBHTTPServer.h"
10
10
 
11
+ #import "FBCommandStatus.h"
11
12
  #import "FBConfiguration.h"
13
+ #import "FBResponsePayload.h"
12
14
  #import "FBTCPSocket.h"
13
15
 
14
16
  static NSData *FBCRLFCRLFData(void)
@@ -39,6 +41,20 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
39
41
  @end
40
42
 
41
43
 
44
+ // Cached result of parsing a connection's request line + headers, kept around while its body is
45
+ // still streaming in so a slow body doesn't cause the header block to be re-found and re-parsed
46
+ // on every single incoming TCP segment.
47
+ @interface FBPendingHTTPRequestHeader : NSObject
48
+ @property (nonatomic, copy) NSString *method;
49
+ @property (nonatomic, copy) NSString *pathAndQuery;
50
+ @property (nonatomic) NSUInteger bodyStart;
51
+ @property (nonatomic) NSUInteger contentLength;
52
+ @end
53
+
54
+ @implementation FBPendingHTTPRequestHeader
55
+ @end
56
+
57
+
42
58
  @interface FBHTTPServer () <FBTCPSocketDelegate>
43
59
 
44
60
  @property (nonatomic, nullable, strong) FBTCPSocket *socket;
@@ -48,6 +64,9 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
48
64
  @property (nonatomic, copy, nullable) NSString *interface;
49
65
  // nw_connection_t isn't NSCopying, so it can't be an NSDictionary key - use NSMapTable instead.
50
66
  @property (nonatomic, strong) NSMapTable<id, NSMutableData *> *connectionBuffers;
67
+ // Per-client cache of the already-parsed request line + headers while its body is still
68
+ // arriving; nil while a client's next unread bytes start with an unparsed header block.
69
+ @property (nonatomic, strong) NSMapTable<id, FBPendingHTTPRequestHeader *> *pendingRequestHeaders;
51
70
 
52
71
  @end
53
72
 
@@ -60,6 +79,8 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
60
79
  _defaultHeaders = [NSMutableDictionary dictionary];
61
80
  _connectionBuffers = [NSMapTable mapTableWithKeyOptions:(NSPointerFunctionsOptions)(NSMapTableObjectPointerPersonality | NSMapTableStrongMemory)
62
81
  valueOptions:(NSPointerFunctionsOptions)NSMapTableStrongMemory];
82
+ _pendingRequestHeaders = [NSMapTable mapTableWithKeyOptions:(NSPointerFunctionsOptions)(NSMapTableObjectPointerPersonality | NSMapTableStrongMemory)
83
+ valueOptions:(NSPointerFunctionsOptions)NSMapTableStrongMemory];
63
84
  }
64
85
  return self;
65
86
  }
@@ -101,6 +122,7 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
101
122
  error:nil];
102
123
  NSMutableString *regexPath = [NSMutableString stringWithString:escapedPath];
103
124
  __block NSInteger diff = 0;
125
+ __block NSUInteger wildcardIndex = 0;
104
126
  [paramRegex enumerateMatchesInString:escapedPath
105
127
  options:(NSMatchingOptions)0
106
128
  range:NSMakeRange(0, escapedPath.length)
@@ -109,7 +131,11 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
109
131
  NSString *capturedString = [escapedPath substringWithRange:result.range];
110
132
  NSString *replacementString;
111
133
  if ([capturedString isEqualToString:@"*"]) {
112
- [keys addObject:@"wildcards"];
134
+ // Only the first wildcard keeps the plain "wildcards" name - later ones get an index
135
+ // suffix so multiple "*" segments in one path don't overwrite each other's capture.
136
+ NSString *wildcardKey = 0 == wildcardIndex ? @"wildcards" : [NSString stringWithFormat:@"wildcards%lu", (unsigned long)wildcardIndex];
137
+ wildcardIndex++;
138
+ [keys addObject:wildcardKey];
113
139
  replacementString = @"(.*?)";
114
140
  } else {
115
141
  NSString *keyString = [escapedPath substringWithRange:[result rangeAtIndex:2]];
@@ -164,6 +190,7 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
164
190
  self.socket = nil;
165
191
  @synchronized (self.connectionBuffers) {
166
192
  [self.connectionBuffers removeAllObjects];
193
+ [self.pendingRequestHeaders removeAllObjects];
167
194
  }
168
195
  _isRunning = NO;
169
196
  }
@@ -181,6 +208,7 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
181
208
  {
182
209
  @synchronized (self.connectionBuffers) {
183
210
  [self.connectionBuffers removeObjectForKey:client];
211
+ [self.pendingRequestHeaders removeObjectForKey:client];
184
212
  }
185
213
  }
186
214
 
@@ -203,72 +231,130 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
203
231
  {
204
232
  while (YES) {
205
233
  NSMutableData *buffer;
234
+ FBPendingHTTPRequestHeader *pending;
206
235
  @synchronized (self.connectionBuffers) {
207
236
  buffer = [self.connectionBuffers objectForKey:client];
208
- }
209
- if (nil == buffer) {
210
- return;
237
+ if (nil == buffer) {
238
+ return;
239
+ }
240
+ pending = [self.pendingRequestHeaders objectForKey:client];
211
241
  }
212
242
 
213
- NSRange headerEndRange = [buffer rangeOfData:FBCRLFCRLFData() options:(NSDataSearchOptions)0 range:NSMakeRange(0, buffer.length)];
214
- if (NSNotFound == headerEndRange.location) {
215
- return;
216
- }
243
+ if (nil == pending) {
244
+ NSRange headerEndRange = [buffer rangeOfData:FBCRLFCRLFData() options:(NSDataSearchOptions)0 range:NSMakeRange(0, buffer.length)];
245
+ if (NSNotFound == headerEndRange.location) {
246
+ // Wait for the rest of the header block to arrive.
247
+ return;
248
+ }
217
249
 
218
- NSData *headerData = [buffer subdataWithRange:NSMakeRange(0, headerEndRange.location)];
219
- NSString *headerString = [[NSString alloc] initWithData:headerData encoding:NSUTF8StringEncoding];
220
- NSArray<NSString *> *lines = [headerString componentsSeparatedByString:@"\r\n"];
221
- if (lines.count < 1) {
222
- [self closeClient:client];
223
- return;
224
- }
250
+ NSData *headerData = [buffer subdataWithRange:NSMakeRange(0, headerEndRange.location)];
251
+ NSString *headerString = [[NSString alloc] initWithData:headerData encoding:NSUTF8StringEncoding];
252
+ NSArray<NSString *> *lines = [headerString componentsSeparatedByString:@"\r\n"];
253
+ if (lines.count < 1) {
254
+ [self respondBadRequestToClient:client];
255
+ return;
256
+ }
225
257
 
226
- NSArray<NSString *> *requestLineParts = [lines.firstObject componentsSeparatedByString:@" "];
227
- if (requestLineParts.count < 2) {
228
- [self closeClient:client];
229
- return;
230
- }
231
- NSString *method = requestLineParts[0].uppercaseString;
232
- NSString *pathAndQuery = requestLineParts[1];
233
-
234
- NSMutableDictionary<NSString *, NSString *> *requestHeaders = [NSMutableDictionary dictionary];
235
- for (NSUInteger i = 1; i < lines.count; i++) {
236
- NSString *line = lines[i];
237
- NSRange colonRange = [line rangeOfString:@":"];
238
- if (NSNotFound == colonRange.location) {
239
- continue;
258
+ NSArray<NSString *> *requestLineParts = [lines.firstObject componentsSeparatedByString:@" "];
259
+ if (requestLineParts.count < 2) {
260
+ [self respondBadRequestToClient:client];
261
+ return;
240
262
  }
241
- NSString *name = [line substringToIndex:colonRange.location];
242
- NSString *value = [[line substringFromIndex:colonRange.location + 1]
243
- stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
244
- requestHeaders[name.lowercaseString] = value;
245
- }
246
263
 
247
- NSUInteger contentLength = (NSUInteger)requestHeaders[@"content-length"].integerValue;
248
- if (contentLength > FBConfiguration.sharedInstance.httpRequestBodySizeLimit) {
249
- // Mirrors CocoaHTTPServer's maxRequestBodySize enforcement. Closes the connection after
250
- // responding, since the rest of the oversized body is still incoming.
251
- RouteResponse *tooLarge = [RouteResponse new];
252
- tooLarge.statusCode = kHTTPStatusCodeRequestEntityTooLarge;
253
- [tooLarge respondWithString:@"Request Entity Too Large"];
254
- [self writeResponse:tooLarge toClient:client thenCloseConnection:YES];
255
- return;
264
+ NSMutableDictionary<NSString *, NSString *> *requestHeaders = [NSMutableDictionary dictionary];
265
+ for (NSUInteger i = 1; i < lines.count; i++) {
266
+ NSString *line = lines[i];
267
+ NSRange colonRange = [line rangeOfString:@":"];
268
+ if (NSNotFound == colonRange.location) {
269
+ continue;
270
+ }
271
+ NSString *name = [line substringToIndex:colonRange.location];
272
+ NSString *value = [[line substringFromIndex:colonRange.location + 1]
273
+ stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
274
+ requestHeaders[name.lowercaseString] = value;
275
+ }
276
+
277
+ NSString *transferEncoding = requestHeaders[@"transfer-encoding"];
278
+ if (transferEncoding.length > 0) {
279
+ // No transfer decoder is implemented at all, so any encoding (chunked or otherwise -
280
+ // including a value only introduced by a duplicate header overwriting "chunked" above)
281
+ // is rejected rather than risking the body being misread as empty and desyncing the rest
282
+ // of the connection's request stream.
283
+ RouteResponse *notImplemented = [RouteResponse new];
284
+ id<FBResponsePayload> notImplementedPayload = FBResponseWithStatus([FBCommandStatus unknownCommandErrorWithMessage:@"Transfer-Encoding is not supported"
285
+ traceback:nil]);
286
+ [notImplementedPayload dispatchWithResponse:notImplemented];
287
+ [self failClient:client withResponse:notImplemented];
288
+ return;
289
+ }
290
+
291
+ NSUInteger contentLength = (NSUInteger)requestHeaders[@"content-length"].integerValue;
292
+ if (contentLength > FBConfiguration.sharedInstance.httpRequestBodySizeLimit) {
293
+ // Mirrors CocoaHTTPServer's maxRequestBodySize enforcement. Closes the connection after
294
+ // responding, since the rest of the oversized body is still incoming.
295
+ RouteResponse *tooLarge = [RouteResponse new];
296
+ id<FBResponsePayload> tooLargePayload = FBResponseWithStatus([FBCommandStatus unknownCommandErrorWithMessage:@"Request Entity Too Large"
297
+ traceback:nil]);
298
+ [tooLargePayload dispatchWithResponse:tooLarge];
299
+ [self failClient:client withResponse:tooLarge];
300
+ return;
301
+ }
302
+
303
+ pending = [FBPendingHTTPRequestHeader new];
304
+ pending.method = requestLineParts[0].uppercaseString;
305
+ pending.pathAndQuery = requestLineParts[1];
306
+ pending.bodyStart = headerEndRange.location + headerEndRange.length;
307
+ pending.contentLength = contentLength;
308
+ @synchronized (self.connectionBuffers) {
309
+ [self.pendingRequestHeaders setObject:pending forKey:client];
310
+ }
256
311
  }
257
- NSUInteger bodyStart = headerEndRange.location + headerEndRange.length;
258
- NSUInteger totalRequestLength = bodyStart + contentLength;
312
+
313
+ NSUInteger totalRequestLength = pending.bodyStart + pending.contentLength;
259
314
  if (buffer.length < totalRequestLength) {
260
- // Wait for the rest of the body to arrive.
315
+ // Wait for the rest of the body to arrive - the parsed header stays cached above, so this
316
+ // doesn't re-scan/re-parse the header block on every subsequently arriving chunk.
261
317
  return;
262
318
  }
263
319
 
264
- NSData *body = contentLength > 0 ? [buffer subdataWithRange:NSMakeRange(bodyStart, contentLength)] : [NSData data];
320
+ NSData *body = pending.contentLength > 0 ? [buffer subdataWithRange:NSMakeRange(pending.bodyStart, pending.contentLength)] : [NSData data];
265
321
 
266
322
  @synchronized (self.connectionBuffers) {
267
323
  [buffer replaceBytesInRange:NSMakeRange(0, totalRequestLength) withBytes:NULL length:0];
324
+ [self.pendingRequestHeaders removeObjectForKey:client];
268
325
  }
269
326
 
270
- [self dispatchMethod:method pathAndQuery:pathAndQuery body:body client:client];
327
+ [self dispatchMethod:pending.method pathAndQuery:pending.pathAndQuery body:body client:client];
328
+ }
329
+ }
330
+
331
+ // Removes the client's buffered state and responds with a closing error response. Removing the
332
+ // buffer synchronously ensures any request bytes still streaming in for this connection are
333
+ // dropped rather than being re-parsed and re-triggering this same response.
334
+ - (void)failClient:(nw_connection_t)client withResponse:(RouteResponse *)response
335
+ {
336
+ @synchronized (self.connectionBuffers) {
337
+ [self.connectionBuffers removeObjectForKey:client];
338
+ [self.pendingRequestHeaders removeObjectForKey:client];
271
339
  }
340
+ [self applyDefaultHeadersToResponse:response];
341
+ [self writeResponse:response toClient:client thenCloseConnection:YES];
342
+ }
343
+
344
+ - (void)respondBadRequestToClient:(nw_connection_t)client
345
+ {
346
+ RouteResponse *badRequest = [RouteResponse new];
347
+ id<FBResponsePayload> payload = FBResponseWithStatus([FBCommandStatus unknownCommandErrorWithMessage:@"The request could not be parsed as valid HTTP"
348
+ traceback:nil]);
349
+ [payload dispatchWithResponse:badRequest];
350
+ [self failClient:client withResponse:badRequest];
351
+ }
352
+
353
+ - (void)applyDefaultHeadersToResponse:(RouteResponse *)response
354
+ {
355
+ [self.defaultHeaders enumerateKeysAndObjectsUsingBlock:^(NSString *field, NSString *value, BOOL *stop) {
356
+ [response setHeader:field value:value];
357
+ }];
272
358
  }
273
359
 
274
360
  - (void)dispatchMethod:(NSString *)method pathAndQuery:(NSString *)pathAndQuery body:(NSData *)body client:(nw_connection_t)client
@@ -300,9 +386,7 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
300
386
  NSURL *url = [NSURL URLWithString:path] ?: [NSURL URLWithString:@"/"];
301
387
  RouteRequest *request = [[RouteRequest alloc] initWithURL:url params:params.copy body:body];
302
388
  RouteResponse *response = [RouteResponse new];
303
- [self.defaultHeaders enumerateKeysAndObjectsUsingBlock:^(NSString *field, NSString *value, BOOL *stop) {
304
- [response setHeader:field value:value];
305
- }];
389
+ [self applyDefaultHeadersToResponse:response];
306
390
 
307
391
  void (^invoke)(void) = ^{
308
392
  route.block(request, response);
@@ -318,8 +402,10 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
318
402
  }
319
403
 
320
404
  RouteResponse *notFound = [RouteResponse new];
321
- notFound.statusCode = kHTTPStatusCodeNotFound;
322
- [notFound respondWithString:@"Not Found"];
405
+ FBCommandStatus *status = [FBCommandStatus unknownCommandErrorWithMessage:nil
406
+ traceback:nil];
407
+ [FBResponseWithStatus(status) dispatchWithResponse:notFound];
408
+ [self applyDefaultHeadersToResponse:notFound];
323
409
  [self writeResponse:notFound toClient:client];
324
410
  }
325
411
 
@@ -380,6 +466,8 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
380
466
  return @"Request Timeout";
381
467
  } else if (kHTTPStatusCodeRequestEntityTooLarge == statusCode) {
382
468
  return @"Request Entity Too Large";
469
+ } else if (kHTTPStatusCodeNotImplemented == statusCode) {
470
+ return @"Not Implemented";
383
471
  } else if (kHTTPStatusCodeInternalServerError == statusCode) {
384
472
  return @"Internal Server Error";
385
473
  }
@@ -78,12 +78,18 @@
78
78
  // NSLocalizedDescriptionKey must be a string, not the underlying NSError itself, or
79
79
  // -[NSError localizedDescription] crashes trying to treat it as one.
80
80
  NSError *underlyingError = nwError ? (NSError *)CFBridgingRelease(nw_error_copy_cf_error(nwError)) : nil;
81
- NSMutableDictionary<NSString *, id> *userInfo = [NSMutableDictionary dictionary];
82
- userInfo[NSLocalizedDescriptionKey] = underlyingError.localizedDescription ?: @"The TCP listener failed to start";
83
- if (underlyingError) {
84
- userInfo[NSUnderlyingErrorKey] = underlyingError;
81
+ if ([underlyingError.domain isEqualToString:NSPOSIXErrorDomain]) {
82
+ // Surface POSIX errors (e.g. EADDRINUSE) directly, since callers like FBWebServer check
83
+ // for them by domain/code on the top-level error to decide whether to retry another port.
84
+ startupError = underlyingError;
85
+ } else {
86
+ NSMutableDictionary<NSString *, id> *userInfo = [NSMutableDictionary dictionary];
87
+ userInfo[NSLocalizedDescriptionKey] = underlyingError.localizedDescription ?: @"The TCP listener failed to start";
88
+ if (underlyingError) {
89
+ userInfo[NSUnderlyingErrorKey] = underlyingError;
90
+ }
91
+ startupError = [NSError errorWithDomain:@"FBTCPSocket" code:2 userInfo:userInfo];
85
92
  }
86
- startupError = [NSError errorWithDomain:@"FBTCPSocket" code:2 userInfo:userInfo];
87
93
  dispatch_semaphore_signal(startupSemaphore);
88
94
  }
89
95
  });
@@ -98,6 +104,10 @@
98
104
  if (error) {
99
105
  *error = startupError;
100
106
  }
107
+ // Cancel rather than just dropping our reference - otherwise a late ready/failed callback
108
+ // can still fire and the port stays bound at the OS level even though the caller was told
109
+ // startup failed.
110
+ nw_listener_cancel(listener);
101
111
  self.listener = nil;
102
112
  return NO;
103
113
  }
@@ -144,11 +154,9 @@
144
154
  }
145
155
  if (nil != content) {
146
156
  dispatch_data_t nonnullContent = (dispatch_data_t _Nonnull)content;
147
- __block NSData *data = nil;
157
+ NSMutableData *data = [NSMutableData data];
148
158
  dispatch_data_apply(nonnullContent, ^bool(dispatch_data_t _Nonnull region, size_t offset, const void * _Nonnull buffer, size_t size) {
149
- NSMutableData *accumulated = [(data ?: [NSData data]) mutableCopy];
150
- [accumulated appendBytes:buffer length:size];
151
- data = accumulated.copy;
159
+ [data appendBytes:buffer length:size];
152
160
  return true;
153
161
  });
154
162
  if (data.length > 0) {
@@ -198,13 +206,19 @@
198
206
 
199
207
  - (void)stop
200
208
  {
209
+ NSArray<nw_connection_t> *clients;
201
210
  @synchronized (self.connectedClients) {
202
- NSArray<nw_connection_t> *clients = self.connectedClients.copy;
211
+ clients = self.connectedClients.copy;
203
212
  [self.connectedClients removeAllObjects];
213
+ }
214
+ // Cancel on socketQueue, the same queue every connection's send/receive is bound to (see
215
+ // -acceptConnection:), so a write already issued just before -stop (e.g. a shutdown route's
216
+ // response) is processed before the cancellation rather than racing it.
217
+ dispatch_async(self.socketQueue, ^{
204
218
  for (nw_connection_t client in clients) {
205
219
  nw_connection_cancel(client);
206
220
  }
207
- }
221
+ });
208
222
 
209
223
  self.delegate = nil;
210
224
  nw_listener_t listener = self.listener;
@@ -261,7 +261,11 @@ static NSString *const FBServerURLEndMarker = @"<-ServerURLHere";
261
261
  return;
262
262
  }
263
263
  [response respondWithString:@"Shutting down"];
264
- [strongSelf.delegate webServerDidRequestShutdown:strongSelf];
264
+ // Deferred so the "Shutting down" response is written to the client before
265
+ // webServerDidRequestShutdown: tears down the server's socket out from under it.
266
+ dispatch_async(dispatch_get_main_queue(), ^{
267
+ [strongSelf.delegate webServerDidRequestShutdown:strongSelf];
268
+ });
265
269
  }];
266
270
 
267
271
  [self registerRouteHandlers:@[FBUnknownCommands.class]];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appium-webdriveragent",
3
- "version": "16.7.0",
3
+ "version": "16.7.2",
4
4
  "description": "Package bundling WebDriverAgent",
5
5
  "keywords": [
6
6
  "Appium",