• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

homebridge / HAP-NodeJS / 29152933358

11 Jul 2026 12:37PM UTC coverage: 66.161% (+0.07%) from 66.09%
29152933358

push

github

bwp91
v2.1.8

1911 of 3368 branches covered (56.74%)

Branch coverage included in aggregate %.

6598 of 9493 relevant lines covered (69.5%)

222.13 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.58
/src/test-utils/HAPHTTPClient.ts
1
import assert from "assert";
4✔
2
import { Agent } from "http";
3
import { HeaderObject, HTTPParser } from "http-parser-js";
4✔
4
import net, { AddressInfo, Socket } from "net";
4✔
5
import { HAPMimeTypes, PairingStates, PairMethods, TLVValues } from "../internal-types";
6
import { HAPHTTPCode, HAPPairingHTTPCode } from "../lib/HAPServer";
7
import { PairingInformation, PermissionTypes } from "../lib/model/AccessoryInfo";
8
import { HAPEncryption, HAPUsername } from "../lib/util/eventedhttp";
9
import * as hapCrypto from "../lib/util/hapCrypto";
4✔
10
import { PromiseTimeout } from "../lib/util/promise-utils";
4✔
11
import * as tlv from "../lib/util/tlv";
4✔
12
import {
13
  AccessoriesResponse,
14
  CharacteristicId,
15
  CharacteristicsReadResponse,
16
  CharacteristicsWriteRequest,
17
  CharacteristicsWriteResponse,
18
  PrepareWriteRequest,
19
  ResourceRequest,
20
} from "../types";
21
import { HAPHTTPError } from "./HAPHTTPError";
4✔
22
import { TLVError } from "./tlvError";
4✔
23

24
export interface HTTPResponse<T = Buffer> {
25
  shouldKeepAlive: boolean;
26
  upgrade: boolean;
27
  statusCode: number;
28
  statusMessage: string;
29
  versionMajor: number;
30
  versionMinor: number;
31
  headers: Record<string, string>;
32
  body: T;
33
  trailers: string[];
34
}
35

36
// Node >= 24.17 (also 22.23 / 26.3) added a "free socket data guard" as part of the fix for
37
// CVE-2026-48931 (HTTP response queue poisoning). While a keep-alive socket sits idle in the
38
// http.Agent's free pool, Node swaps its low-level `handle.onread` for a guard that destroys the
39
// socket the instant any unsolicited data arrives on it.
40
//
41
// This test client deliberately reaches into the agent's free pool and reuses that idle socket to
42
// receive server-pushed `EVENT/1.0` notifications — which the guard treats as poisoning and kills.
43
// We cannot use a fresh socket instead, because the HAP encryption keys are bound to this exact
44
// server-side connection. So we neutralise the guard by restoring the socket's default `onread`.
45
//
46
// The default read callback (`onStreamRead`) is an internal, un-importable function, but it is the
47
// same instance on every socket handle. We harvest a reference once from a throwaway loopback pair.
48
type HandleOnread = (...args: unknown[]) => void;
49
interface SocketWithHandle {
50
  _handle?: { onread: HandleOnread } | null;
51
}
52

53
// http.Agent doesn't expose its internal socket pool in its public types.
54
interface AgentWithFreeSockets {
55
  freeSockets: Record<string, Socket[]>;
56
}
57

58
let defaultOnread: HandleOnread | undefined;
59

60
async function restoreFreeSocketDataGuard(socket: Socket): Promise<void> {
61
  const handle = (socket as unknown as SocketWithHandle)._handle;
52✔
62
  if (!handle) {
52!
63
    return;
×
64
  }
65

66
  if (!defaultOnread) {
52✔
67
    defaultOnread = await new Promise<HandleOnread>((resolve, reject) => {
4✔
68
      const server = net.createServer();
4✔
69
      server.listen(0, "127.0.0.1", () => {
4✔
70
        const port = (server.address() as AddressInfo).port;
4✔
71
        const probe = net.connect(port, "127.0.0.1", () => {
4✔
72
          const onread = (probe as unknown as SocketWithHandle)._handle!.onread;
4✔
73
          probe.destroy();
4✔
74
          server.close();
4✔
75
          resolve(onread);
4✔
76
        });
77
        probe.on("error", reject);
4✔
78
      });
79
    });
80
  }
81

82
  handle.onread = defaultOnread;
52✔
83
}
84

85
/**
86
 * A http client that wraps around a http agent.
87
 */
88
export class HAPHTTPClient {
4✔
89
  private readonly agent: Agent;
90
  private readonly address: string;
91
  private readonly port: number;
92

93
  private currentSocket?: Socket;
94
  private encryption?: HAPEncryption;
95

96
  private currentDataListener?: (data: Buffer) => void;
97
  private dataQueue: Buffer[] = [];
52✔
98

99
  constructor(agent: Agent, address: string, port: number) {
100
    this.agent = agent;
52✔
101
    this.address = address;
52✔
102
    this.port = port;
52✔
103
  }
104

105
  async attachSocket(): Promise<void> {
106
    expect(this.currentSocket).toBeUndefined();
52✔
107
    expect(this.currentDataListener).toBeUndefined();
52✔
108

109
    // we extract the underlying TCP socket!
110
    const freeSockets = (this.agent as unknown as AgentWithFreeSockets).freeSockets;
52✔
111
    expect(Object.values(freeSockets).length).toBe(1);
52✔
112
    this.currentSocket = Object.values(freeSockets)[0][0];
52✔
113

114
    // undo Node's idle free-socket data guard so server-pushed events reach us (see note above).
115
    await restoreFreeSocketDataGuard(this.currentSocket);
52✔
116

117
    this.currentDataListener = data => {
52✔
118
      // packets shall fit into a single TCP segment, no need to write a parser!
119
      this.dataQueue.push(data);
66✔
120
    };
121
    this.currentSocket.on("data", this.currentDataListener);
52✔
122
  }
123

124
  get receiveBufferCount(): number {
125
    return this.dataQueue.length;
24✔
126
  }
127

128
  enableEncryption(encryption: HAPEncryption): void {
129
    this.encryption = encryption;
50✔
130
  }
131

132
  disableEncryption(): void {
133
    this.encryption = undefined;
×
134
  }
135

136
  popReceiveBuffer(): Buffer {
137
    expect(this.currentSocket).toBeDefined();
66✔
138
    expect(this.dataQueue.length > 0).toBeTruthy();
66✔
139
    const buffer = this.dataQueue.splice(0, 1)[0];
66✔
140
    if (this.encryption) {
66✔
141
      return hapCrypto.layerDecrypt(buffer, this.encryption);
54✔
142
    }
143
    return buffer;
12✔
144
  }
145

146
  formatHTTPRequest(
147
    method: "GET" | "POST" | "PUT" | "DELETE",
148
    route: string,
149
    data?: Buffer,
150
    contentType = "application/json",
32✔
151
  ): Buffer {
152
    expect(!!data || method === "GET").toBeTruthy();
60✔
153
    const buffer = Buffer.from(`${method} ${route} HTTP/1.1\r\n` +
60✔
154
      "Accept: application/json, text/plain, */*\r\n" +
155
      "User-Agent: test-util\r\n" +
156
      "Host: " + this.address + ":" + this.port + "\r\n" +
157
      "Connection: keep-alive\r\n" +
158
      (data
60✔
159
        ? "Content-Type: " + contentType + "\r\n" +
160
          "Content-Length: " + data.length + "\r\n"
161
        : ""
162
      ) +
163
      "\r\n");
164

165
    if (data) {
60✔
166
      return Buffer.concat([buffer, data]);
28✔
167
    }
168

169
    return buffer;
32✔
170
  }
171

172
  async writeHTTPRequest(method: "GET" | "POST" | "PUT" | "DELETE", route: string, data?: Buffer, contentType?: string): Promise<HTTPResponse> {
173
    const httpRequest = this.formatHTTPRequest(method, route, data, contentType);
54✔
174
    this.write(httpRequest);
54✔
175

176
    await PromiseTimeout(20);
54✔
177

178
    const responseBuffer = this.popReceiveBuffer();
54✔
179
    return this.parseHTTPResponse(responseBuffer);
54✔
180
  }
181

182
  write(data: Buffer): void {
183
    if (this.encryption) {
60✔
184
      data = hapCrypto.layerEncrypt(data, this.encryption);
54✔
185
    }
186
    expect(this.currentSocket).toBeDefined();
60✔
187
    this.currentSocket!.write(data);
60✔
188
  }
189

190
  releaseSocket(): void {
191
    if (this.currentSocket && this.currentDataListener) {
52!
192
      this.currentSocket.removeListener("data", this.currentDataListener);
52✔
193
      this.currentDataListener = undefined;
52✔
194
    }
195

196
    expect(this.currentDataListener).toBeUndefined();
52✔
197

198
    this.currentSocket = undefined;
52✔
199
  }
200

201
  async sendAddPairingRequest(identifier: HAPUsername, publicKey: Buffer, permission: PermissionTypes): Promise<void> {
202
    const requestTLV = tlv.encode(
2✔
203
      TLVValues.METHOD, PairMethods.ADD_PAIRING,
204
      TLVValues.STATE, PairingStates.M1,
205
      TLVValues.IDENTIFIER, identifier,
206
      TLVValues.PUBLIC_KEY, publicKey,
207
      TLVValues.PERMISSIONS, permission,
208
    );
209

210
    await this.sendPairingsRequest(requestTLV);
2✔
211
  }
212

213
  async sendRemovePairingRequest(identifier: HAPUsername): Promise<void> {
214
    const requestTLV = tlv.encode(
2✔
215
      TLVValues.METHOD, PairMethods.REMOVE_PAIRING,
216
      TLVValues.STATE, PairingStates.M1,
217
      TLVValues.IDENTIFIER, identifier,
218
    );
219

220
    await this.sendPairingsRequest(requestTLV);
2✔
221
  }
222

223
  async sendListPairingsRequest(): Promise<PairingInformation[]> {
224
    const requestTLV = tlv.encode(
2✔
225
      TLVValues.METHOD, PairMethods.LIST_PAIRINGS,
226
      TLVValues.STATE, PairingStates.M1,
227
    );
228

229
    const responseBody = await this.sendPairingsRequest(requestTLV);
2✔
230
    const tlvDataList = tlv.decodeList(responseBody.subarray(3), TLVValues.IDENTIFIER);
2✔
231

232
    const result: PairingInformation[] = [];
2✔
233

234
    for (const element of tlvDataList) {
2✔
235
      result.push({
4✔
236
        username: element[TLVValues.IDENTIFIER].toString(),
237
        publicKey: element[TLVValues.PUBLIC_KEY],
238
        permission: element[TLVValues.PERMISSIONS].readUInt8(0),
239
      });
240
    }
241

242
    return result;
2✔
243
  }
244

245
  private async sendPairingsRequest(requestTLV: Buffer): Promise<Buffer> {
246
    const httpResponse = await this.writeHTTPRequest("POST", "/pairings", requestTLV, HAPMimeTypes.PAIRING_TLV8);
6✔
247

248
    // `/pairings` errors are transported via the tlv8 record
249
    expect(httpResponse.statusCode).toEqual(HAPPairingHTTPCode.OK);
6✔
250
    expect(httpResponse.headers["Content-Type"]).toEqual(HAPMimeTypes.PAIRING_TLV8);
6✔
251

252
    const tlvData = tlv.decode(httpResponse.body);
6✔
253
    expect(tlvData[TLVValues.STATE].readUInt8(0)).toEqual(PairingStates.M2);
6✔
254

255
    if (tlvData[TLVValues.ERROR_CODE]) {
6!
256
      throw new TLVError(tlvData[TLVValues.ERROR_CODE].readUInt8(0));
×
257
    }
258

259
    // we return the raw buffer because LIST_PAIRINGS has some custom decoding strategies!
260
    return httpResponse.body;
6✔
261
  }
262

263
  public async sendAccessoriesRequest(): Promise<AccessoriesResponse> {
264
    const httpResponse = await this.writeHTTPRequest("GET", "/accessories");
4✔
265
    expect(httpResponse.headers["Content-Type"]).toEqual(HAPMimeTypes.HAP_JSON);
4✔
266
    const jsonBody = JSON.parse(httpResponse.body.toString());
4✔
267

268
    if (httpResponse.statusCode !== HAPPairingHTTPCode.OK) {
4!
269
      throw new HAPHTTPError(httpResponse.statusCode, jsonBody.status);
×
270
    }
271

272
    return jsonBody;
4✔
273
  }
274

275
  public async sendCharacteristicRead(
276
    ids: CharacteristicId[],
277
    includeMeta?: boolean,
278
    includePerms?: boolean,
279
    includeType?: boolean,
280
    includeEvent?: boolean,
281
  ): Promise<HTTPResponse<CharacteristicsReadResponse>> {
282
    assert(ids.length > 0);
4✔
283
    let query = "?id=" + ids.map(id => id.aid + "." + id.iid).join(",");
8✔
284

285
    if (includeMeta) {
4!
286
      query += "&meta=" + (includeMeta ? "true" : "false");
×
287
    }
288
    if (includePerms) {
4!
289
      query += "&perms=" + (includePerms ? "1" : "0");
×
290
    }
291
    if (includeType) {
4✔
292
      query += "&type=" + (includeType ? "true" : "false");
2!
293
    }
294
    if (includeEvent) {
4✔
295
      query += "&ev=" + (includeEvent ? "1" : "0");
2!
296
    }
297

298
    const httpResponse = await this.writeHTTPRequest("GET", "/characteristics" + query);
4✔
299
    expect(httpResponse.headers["Content-Type"]).toEqual(HAPMimeTypes.HAP_JSON);
4✔
300

301
    const body = JSON.parse(httpResponse.body.toString());
4✔
302
    if (!httpResponse.statusCode.toString().startsWith("2")) {
4!
303
      throw new HAPHTTPError(httpResponse.statusCode, body.status);
×
304
    }
305

306
    return {
4✔
307
      ...httpResponse,
308
      body: body,
309
    };
310
  }
311

312
  public async sendCharacteristicWrite(writeRequest: CharacteristicsWriteRequest): Promise<HTTPResponse<CharacteristicsWriteResponse | undefined>> {
313
    const httpResponse = await this.writeHTTPRequest("PUT", "/characteristics", Buffer.from(JSON.stringify(writeRequest)), HAPMimeTypes.HAP_JSON);
6✔
314
    if (httpResponse.statusCode !== HAPHTTPCode.NO_CONTENT) {
6✔
315
      expect(httpResponse.headers["Content-Type"]).toEqual(HAPMimeTypes.HAP_JSON);
4✔
316
    }
317

318
    if (!httpResponse.statusCode.toString().startsWith("2")) {
6!
319
      const jsonBody = JSON.parse(httpResponse.body.toString());
×
320
      throw new HAPHTTPError(httpResponse.statusCode, jsonBody.status);
×
321
    }
322

323
    return {
6✔
324
      ...httpResponse,
325
      body: httpResponse.body.length > 0 ? JSON.parse(httpResponse.body.toString()) : undefined,
6✔
326
    };
327
  }
328

329
  public async sendPrepareWrite(prepareWrite: PrepareWriteRequest): Promise<void> {
330
    const httpResponse = await this.writeHTTPRequest("PUT", "/prepare", Buffer.from(JSON.stringify(prepareWrite)), HAPMimeTypes.HAP_JSON);
4✔
331
    expect(httpResponse.headers["Content-Type"]).toEqual(HAPMimeTypes.HAP_JSON);
4✔
332

333
    if (httpResponse.statusCode !== HAPHTTPCode.OK) {
4!
334
      const jsonBody = JSON.parse(httpResponse.body.toString());
×
335
      throw new HAPHTTPError(httpResponse.statusCode, jsonBody.status);
×
336
    }
337
  }
338

339
  public async sendResourceRequest(resourceRequest: ResourceRequest): Promise<Buffer> {
340
    const httpResponse = await this.writeHTTPRequest("POST", "/resource", Buffer.from(JSON.stringify(resourceRequest)), HAPMimeTypes.HAP_JSON);
2✔
341

342
    if (httpResponse.statusCode !== HAPHTTPCode.OK) {
2!
343
      expect(httpResponse.headers["Content-Type"]).toEqual(HAPMimeTypes.HAP_JSON);
×
344
      const jsonBody = JSON.parse(httpResponse.body.toString());
×
345
      throw new HAPHTTPError(httpResponse.statusCode, jsonBody.status);
×
346
    }
347

348
    expect(httpResponse.headers["Content-Type"]).toEqual(HAPMimeTypes.IMAGE_JPEG);
2✔
349
    return httpResponse.body;
2✔
350
  }
351

352
  parseHTTPResponse(input: Buffer): HTTPResponse {
353
    // taken and adapted from https://github.com/creationix/http-parser-js/blob/88c665381470e27cd428a728447a13dce198f782/standalone-example.js#L73
354
    const parser = new HTTPParser(HTTPParser.RESPONSE);
54✔
355

356
    let complete = false;
54✔
357
    let shouldKeepAlive = false;
54✔
358
    let upgrade = false;
54✔
359
    let statusCode = 0;
54✔
360
    let statusMessage = "";
54✔
361
    let versionMajor = 0;
54✔
362
    let versionMinor = 0;
54✔
363
    let headers: HeaderObject = [];
54✔
364
    let trailers: string[] = [];
54✔
365
    const bodyChunks: Buffer[] = [];
54✔
366

367
    parser[HTTPParser.kOnHeadersComplete] = info => {
54✔
368
      shouldKeepAlive = info.shouldKeepAlive;
54✔
369
      upgrade = info.upgrade;
54✔
370
      statusCode = info.statusCode;
54✔
371
      statusMessage = info.statusMessage;
54✔
372
      versionMajor = info.versionMajor;
54✔
373
      versionMinor = info.versionMinor;
54✔
374
      headers = info.headers;
54✔
375
    };
376

377
    parser[HTTPParser.kOnBody] = (chunk, offset, length) => {
54✔
378
      bodyChunks.push(chunk.subarray(offset, offset + length));
52✔
379
    };
380

381
    // that's the event for trailers!
382
    parser[HTTPParser.kOnHeaders] = t => {
54✔
383
      trailers = t;
×
384
    };
385

386
    parser[HTTPParser.kOnMessageComplete] = () => {
54✔
387
      complete = true;
54✔
388
    };
389

390
    // Since we are sending the entire Buffer at once here all callbacks above happen synchronously.
391
    // The parser does not do _anything_ asynchronous.
392
    // However, you can of course call execute() multiple times with multiple chunks, e.g. from a stream.
393
    // But then you have to refactor the entire logic to be async (e.g. resolve a Promise in kOnMessageComplete and add timeout logic).
394
    parser.execute(input);
54✔
395
    parser.finish();
54✔
396

397
    if (!complete) {
54!
398
      throw new Error("Failed to parse input: " + input.toString());
×
399
    }
400

401
    const body = Buffer.concat(bodyChunks);
54✔
402

403
    return {
54✔
404
      shouldKeepAlive,
405
      upgrade,
406
      statusCode,
407
      statusMessage,
408
      versionMajor,
409
      versionMinor,
410
      headers: this.headersArrayToObject(headers),
411
      body,
412
      trailers,
413
    };
414
  }
415

416
  private headersArrayToObject(headers: HeaderObject): Record<string, string> {
417
    expect(headers.length % 2).toBe(0);
54✔
418

419
    const result: Record<string, string> = {};
54✔
420

421
    for (let i = 0; i < headers.length; i += 2) {
54✔
422
      result[headers[i]] = headers[i+1];
212✔
423
    }
424

425
    return result;
54✔
426
  }
427
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc