Skip to content

Commit d0fa85c

Browse files
committed
fix: log connections that timeout, abort, or send unparable data
1 parent 6ba23e4 commit d0fa85c

File tree

3 files changed

+170
-1
lines changed

3 files changed

+170
-1
lines changed

src/http/plugins/log-request.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
import { Socket } from 'net'
12
import fastifyPlugin from 'fastify-plugin'
23
import { logSchema, redactQueryParamFromRequest } from '@internal/monitoring'
34
import { trace } from '@opentelemetry/api'
45
import { FastifyRequest } from 'fastify/types/request'
56
import { FastifyReply } from 'fastify/types/reply'
7+
import { IncomingMessage } from 'http'
8+
import { getConfig } from '../../config'
9+
import { parsePartialHttp, PartialHttpData } from '@internal/http'
10+
import { FastifyInstance } from 'fastify'
611

712
interface RequestLoggerOptions {
813
excludeUrls?: string[]
@@ -22,13 +27,65 @@ declare module 'fastify' {
2227
}
2328
}
2429

30+
const { version } = getConfig()
31+
2532
/**
2633
* Request logger plugin
2734
* @param options
2835
*/
2936
export const logRequest = (options: RequestLoggerOptions) =>
3037
fastifyPlugin(
3138
async (fastify) => {
39+
// Watch for connections that timeout or disconnect before complete HTTP headers are received
40+
// Log if socket closes before request is triggered
41+
fastify.server.on('connection', (socket) => {
42+
let hasRequest = false
43+
const startTime = Date.now()
44+
const partialData: Buffer[] = []
45+
// Only store 2kb per request
46+
const captureByteLimit = 2048
47+
48+
// Capture partial data sent before connection closes
49+
const onData = (chunk: Buffer) => {
50+
const remaining = captureByteLimit - partialData.length
51+
if (remaining > 0) {
52+
partialData.push(chunk.subarray(0, Math.min(chunk.length, remaining)))
53+
}
54+
55+
if (partialData.length >= captureByteLimit) {
56+
socket.removeListener('data', onData)
57+
}
58+
}
59+
socket.on('data', onData)
60+
61+
// Track if this socket ever receives an HTTP request
62+
const onRequest = (req: IncomingMessage) => {
63+
if (req.socket === socket) {
64+
hasRequest = true
65+
socket.removeListener('data', onData)
66+
fastify.server.removeListener('request', onRequest)
67+
}
68+
}
69+
fastify.server.on('request', onRequest)
70+
71+
socket.once('close', () => {
72+
socket.removeListener('data', onData)
73+
fastify.server.removeListener('request', onRequest)
74+
if (hasRequest) {
75+
return
76+
}
77+
78+
const parsedHttp = parsePartialHttp(partialData)
79+
const req = createPartialLogRequest(fastify, socket, parsedHttp, startTime)
80+
81+
doRequestLog(req, {
82+
excludeUrls: options.excludeUrls,
83+
statusCode: 'ABORTED CONN',
84+
responseTime: (Date.now() - req.startTime) / 1000,
85+
})
86+
})
87+
})
88+
3289
fastify.addHook('onRequest', async (req, res) => {
3390
req.startTime = Date.now()
3491

@@ -95,7 +152,7 @@ export const logRequest = (options: RequestLoggerOptions) =>
95152
interface LogRequestOptions {
96153
reply?: FastifyReply
97154
excludeUrls?: string[]
98-
statusCode: number | 'ABORTED REQ' | 'ABORTED RES'
155+
statusCode: number | 'ABORTED REQ' | 'ABORTED RES' | 'ABORTED CONN'
99156
responseTime: number
100157
}
101158

@@ -142,3 +199,34 @@ function getFirstDefined<T>(...values: any[]): T | undefined {
142199
}
143200
return undefined
144201
}
202+
203+
/**
204+
* Creates a minimal FastifyRequest from partial HTTP data.
205+
* Used for consistent logging when request parsing fails.
206+
*/
207+
export function createPartialLogRequest(
208+
fastify: FastifyInstance,
209+
socket: Socket,
210+
httpData: PartialHttpData,
211+
startTime: number
212+
) {
213+
return {
214+
method: httpData.method,
215+
url: httpData.url,
216+
headers: httpData.headers,
217+
ip: socket.remoteAddress || 'unknown',
218+
id: 'no-request',
219+
log: fastify.log.child({
220+
tenantId: httpData.tenantId,
221+
project: httpData.tenantId,
222+
reqId: 'no-request',
223+
appVersion: version,
224+
dataLength: httpData.length,
225+
}),
226+
startTime,
227+
tenantId: httpData.tenantId,
228+
raw: {},
229+
routeOptions: { config: {} },
230+
resources: [],
231+
} as unknown as FastifyRequest
232+
}

src/internal/http/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './agent'
2+
export * from './partial-http-parser'
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { getConfig } from '../../config'
2+
3+
const { isMultitenant, requestXForwardedHostRegExp } = getConfig()
4+
5+
const REQUEST_LINE_REGEX = /^([A-Z]+)\s+(\S+)(?:\s+HTTP\/[\d.]+)?$/i
6+
const LINE_SPLIT_REGEX = /\r?\n/
7+
// Validate header name (RFC 7230 token characters)
8+
const HEADER_NAME_REGEX = /^[a-z0-9!#$%&'*+\-.^_`|~]+$/
9+
10+
const MAX_HEADER_LINES = 100
11+
12+
export interface PartialHttpData {
13+
method: string
14+
url: string
15+
headers: Record<string, string>
16+
tenantId: string
17+
length: number
18+
}
19+
20+
/**
21+
* Parses partial HTTP request data from raw buffers.
22+
* Returns defaults if parsing fails.
23+
*/
24+
export function parsePartialHttp(dataChunks: Buffer[]): PartialHttpData {
25+
const result: PartialHttpData = {
26+
method: 'UNKNOWN',
27+
url: '/',
28+
headers: {},
29+
tenantId: isMultitenant ? 'unknown' : 'storage-single-tenant',
30+
length: 0,
31+
}
32+
33+
if (dataChunks.length === 0) {
34+
return result
35+
}
36+
37+
try {
38+
const partialData = Buffer.concat(dataChunks).toString('utf8')
39+
const lines = partialData.split(LINE_SPLIT_REGEX)
40+
result.length = partialData.length
41+
42+
// Parse request line: "METHOD /path HTTP/version"
43+
if (lines[0]) {
44+
const requestLine = lines[0].match(REQUEST_LINE_REGEX)
45+
if (requestLine) {
46+
result.method = requestLine[1].toUpperCase()
47+
result.url = requestLine[2]
48+
}
49+
}
50+
51+
// Parse headers (skip line 0, limit total lines)
52+
const headerLineLimit = Math.min(lines.length, MAX_HEADER_LINES + 1)
53+
for (let i = 1; i < headerLineLimit; i++) {
54+
const line = lines[i]
55+
if (!line || line.trim() === '') continue
56+
57+
const colonIndex = line.indexOf(':')
58+
if (colonIndex > 0) {
59+
const field = line.substring(0, colonIndex).trim().toLowerCase()
60+
const value = line.substring(colonIndex + 1).trim()
61+
if (HEADER_NAME_REGEX.test(field)) {
62+
result.headers[field] = value
63+
}
64+
}
65+
}
66+
67+
// Extract tenantId from x-forwarded-host if multitenant
68+
if (isMultitenant && requestXForwardedHostRegExp && result.headers['x-forwarded-host']) {
69+
const match = result.headers['x-forwarded-host'].match(requestXForwardedHostRegExp)
70+
if (match && match[1]) {
71+
result.tenantId = match[1]
72+
}
73+
}
74+
} catch {
75+
// Parsing failed - return defaults
76+
// This catches malformed UTF-8, regex errors, etc.
77+
}
78+
79+
return result
80+
}

0 commit comments

Comments
 (0)