appium-webdriveragent 16.7.1 → 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,9 @@
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
+
1
7
  ## [16.7.1](https://github.com/appium/WebDriverAgent/compare/v16.7.0...v16.7.1) (2026-08-23)
2
8
 
3
9
  ### Bug Fixes
@@ -15,11 +15,11 @@
15
15
  <key>CFBundlePackageType</key>
16
16
  <string>FMWK</string>
17
17
  <key>CFBundleShortVersionString</key>
18
- <string>16.7.1</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.1</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
 
@@ -41,6 +41,20 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
41
41
  @end
42
42
 
43
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
+
44
58
  @interface FBHTTPServer () <FBTCPSocketDelegate>
45
59
 
46
60
  @property (nonatomic, nullable, strong) FBTCPSocket *socket;
@@ -50,6 +64,9 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
50
64
  @property (nonatomic, copy, nullable) NSString *interface;
51
65
  // nw_connection_t isn't NSCopying, so it can't be an NSDictionary key - use NSMapTable instead.
52
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;
53
70
 
54
71
  @end
55
72
 
@@ -62,6 +79,8 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
62
79
  _defaultHeaders = [NSMutableDictionary dictionary];
63
80
  _connectionBuffers = [NSMapTable mapTableWithKeyOptions:(NSPointerFunctionsOptions)(NSMapTableObjectPointerPersonality | NSMapTableStrongMemory)
64
81
  valueOptions:(NSPointerFunctionsOptions)NSMapTableStrongMemory];
82
+ _pendingRequestHeaders = [NSMapTable mapTableWithKeyOptions:(NSPointerFunctionsOptions)(NSMapTableObjectPointerPersonality | NSMapTableStrongMemory)
83
+ valueOptions:(NSPointerFunctionsOptions)NSMapTableStrongMemory];
65
84
  }
66
85
  return self;
67
86
  }
@@ -103,6 +122,7 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
103
122
  error:nil];
104
123
  NSMutableString *regexPath = [NSMutableString stringWithString:escapedPath];
105
124
  __block NSInteger diff = 0;
125
+ __block NSUInteger wildcardIndex = 0;
106
126
  [paramRegex enumerateMatchesInString:escapedPath
107
127
  options:(NSMatchingOptions)0
108
128
  range:NSMakeRange(0, escapedPath.length)
@@ -111,7 +131,11 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
111
131
  NSString *capturedString = [escapedPath substringWithRange:result.range];
112
132
  NSString *replacementString;
113
133
  if ([capturedString isEqualToString:@"*"]) {
114
- [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];
115
139
  replacementString = @"(.*?)";
116
140
  } else {
117
141
  NSString *keyString = [escapedPath substringWithRange:[result rangeAtIndex:2]];
@@ -166,6 +190,7 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
166
190
  self.socket = nil;
167
191
  @synchronized (self.connectionBuffers) {
168
192
  [self.connectionBuffers removeAllObjects];
193
+ [self.pendingRequestHeaders removeAllObjects];
169
194
  }
170
195
  _isRunning = NO;
171
196
  }
@@ -183,6 +208,7 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
183
208
  {
184
209
  @synchronized (self.connectionBuffers) {
185
210
  [self.connectionBuffers removeObjectForKey:client];
211
+ [self.pendingRequestHeaders removeObjectForKey:client];
186
212
  }
187
213
  }
188
214
 
@@ -205,72 +231,130 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
205
231
  {
206
232
  while (YES) {
207
233
  NSMutableData *buffer;
234
+ FBPendingHTTPRequestHeader *pending;
208
235
  @synchronized (self.connectionBuffers) {
209
236
  buffer = [self.connectionBuffers objectForKey:client];
210
- }
211
- if (nil == buffer) {
212
- return;
237
+ if (nil == buffer) {
238
+ return;
239
+ }
240
+ pending = [self.pendingRequestHeaders objectForKey:client];
213
241
  }
214
242
 
215
- NSRange headerEndRange = [buffer rangeOfData:FBCRLFCRLFData() options:(NSDataSearchOptions)0 range:NSMakeRange(0, buffer.length)];
216
- if (NSNotFound == headerEndRange.location) {
217
- return;
218
- }
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
+ }
219
249
 
220
- NSData *headerData = [buffer subdataWithRange:NSMakeRange(0, headerEndRange.location)];
221
- NSString *headerString = [[NSString alloc] initWithData:headerData encoding:NSUTF8StringEncoding];
222
- NSArray<NSString *> *lines = [headerString componentsSeparatedByString:@"\r\n"];
223
- if (lines.count < 1) {
224
- [self closeClient:client];
225
- return;
226
- }
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
+ }
227
257
 
228
- NSArray<NSString *> *requestLineParts = [lines.firstObject componentsSeparatedByString:@" "];
229
- if (requestLineParts.count < 2) {
230
- [self closeClient:client];
231
- return;
232
- }
233
- NSString *method = requestLineParts[0].uppercaseString;
234
- NSString *pathAndQuery = requestLineParts[1];
235
-
236
- NSMutableDictionary<NSString *, NSString *> *requestHeaders = [NSMutableDictionary dictionary];
237
- for (NSUInteger i = 1; i < lines.count; i++) {
238
- NSString *line = lines[i];
239
- NSRange colonRange = [line rangeOfString:@":"];
240
- if (NSNotFound == colonRange.location) {
241
- continue;
258
+ NSArray<NSString *> *requestLineParts = [lines.firstObject componentsSeparatedByString:@" "];
259
+ if (requestLineParts.count < 2) {
260
+ [self respondBadRequestToClient:client];
261
+ return;
242
262
  }
243
- NSString *name = [line substringToIndex:colonRange.location];
244
- NSString *value = [[line substringFromIndex:colonRange.location + 1]
245
- stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
246
- requestHeaders[name.lowercaseString] = value;
247
- }
248
263
 
249
- NSUInteger contentLength = (NSUInteger)requestHeaders[@"content-length"].integerValue;
250
- if (contentLength > FBConfiguration.sharedInstance.httpRequestBodySizeLimit) {
251
- // Mirrors CocoaHTTPServer's maxRequestBodySize enforcement. Closes the connection after
252
- // responding, since the rest of the oversized body is still incoming.
253
- RouteResponse *tooLarge = [RouteResponse new];
254
- tooLarge.statusCode = kHTTPStatusCodeRequestEntityTooLarge;
255
- [tooLarge respondWithString:@"Request Entity Too Large"];
256
- [self writeResponse:tooLarge toClient:client thenCloseConnection:YES];
257
- 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
+ }
258
311
  }
259
- NSUInteger bodyStart = headerEndRange.location + headerEndRange.length;
260
- NSUInteger totalRequestLength = bodyStart + contentLength;
312
+
313
+ NSUInteger totalRequestLength = pending.bodyStart + pending.contentLength;
261
314
  if (buffer.length < totalRequestLength) {
262
- // 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.
263
317
  return;
264
318
  }
265
319
 
266
- 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];
267
321
 
268
322
  @synchronized (self.connectionBuffers) {
269
323
  [buffer replaceBytesInRange:NSMakeRange(0, totalRequestLength) withBytes:NULL length:0];
324
+ [self.pendingRequestHeaders removeObjectForKey:client];
270
325
  }
271
326
 
272
- [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];
273
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
+ }];
274
358
  }
275
359
 
276
360
  - (void)dispatchMethod:(NSString *)method pathAndQuery:(NSString *)pathAndQuery body:(NSData *)body client:(nw_connection_t)client
@@ -302,9 +386,7 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
302
386
  NSURL *url = [NSURL URLWithString:path] ?: [NSURL URLWithString:@"/"];
303
387
  RouteRequest *request = [[RouteRequest alloc] initWithURL:url params:params.copy body:body];
304
388
  RouteResponse *response = [RouteResponse new];
305
- [self.defaultHeaders enumerateKeysAndObjectsUsingBlock:^(NSString *field, NSString *value, BOOL *stop) {
306
- [response setHeader:field value:value];
307
- }];
389
+ [self applyDefaultHeadersToResponse:response];
308
390
 
309
391
  void (^invoke)(void) = ^{
310
392
  route.block(request, response);
@@ -323,6 +405,7 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
323
405
  FBCommandStatus *status = [FBCommandStatus unknownCommandErrorWithMessage:nil
324
406
  traceback:nil];
325
407
  [FBResponseWithStatus(status) dispatchWithResponse:notFound];
408
+ [self applyDefaultHeadersToResponse:notFound];
326
409
  [self writeResponse:notFound toClient:client];
327
410
  }
328
411
 
@@ -383,6 +466,8 @@ static NSData * _Nonnull FBUTF8Data(NSString *string)
383
466
  return @"Request Timeout";
384
467
  } else if (kHTTPStatusCodeRequestEntityTooLarge == statusCode) {
385
468
  return @"Request Entity Too Large";
469
+ } else if (kHTTPStatusCodeNotImplemented == statusCode) {
470
+ return @"Not Implemented";
386
471
  } else if (kHTTPStatusCodeInternalServerError == statusCode) {
387
472
  return @"Internal Server Error";
388
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.1",
3
+ "version": "16.7.2",
4
4
  "description": "Package bundling WebDriverAgent",
5
5
  "keywords": [
6
6
  "Appium",