Coverage Report

Created: 2026-06-01 06:32

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/src/swift-nio/Sources/NIOHTTP1/HTTPHeaderValidator.swift
Line
Count
Source
1
//===----------------------------------------------------------------------===//
2
//
3
// This source file is part of the SwiftNIO open source project
4
//
5
// Copyright (c) 2022 Apple Inc. and the SwiftNIO project authors
6
// Licensed under Apache License v2.0
7
//
8
// See LICENSE.txt for license information
9
// See CONTRIBUTORS.txt for the list of SwiftNIO project authors
10
//
11
// SPDX-License-Identifier: Apache-2.0
12
//
13
//===----------------------------------------------------------------------===//
14
import NIOCore
15
16
/// A ChannelHandler to validate that outbound request headers are spec-compliant.
17
///
18
/// The HTTP RFCs constrain the bytes that are validly present within a HTTP/1.1 header block.
19
/// ``NIOHTTPRequestHeadersValidator`` polices this constraint and ensures that only valid header blocks
20
/// are emitted on the network. If a header block is invalid, then ``NIOHTTPRequestHeadersValidator``
21
/// will send a ``HTTPParserError/invalidHeaderToken``.
22
///
23
/// ``NIOHTTPRequestHeadersValidator`` will also validate that the HTTP trailers are within specification,
24
/// if they are present.
25
public final class NIOHTTPRequestHeadersValidator: ChannelOutboundHandler, RemovableChannelHandler {
26
    public typealias OutboundIn = HTTPClientRequestPart
27
    public typealias OutboundOut = HTTPClientRequestPart
28
29
0
    public init() {}
30
31
0
    public func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise<Void>?) {
32
0
        switch NIOHTTPRequestHeadersValidator.unwrapOutboundIn(data) {
33
0
        case .head(let head):
34
0
            guard Self.uriOnlyContainsAllowedCharacters(head.uri), head.method.isValidToSend,
35
0
                head.headers.areValidToSend
36
0
            else {
37
0
                promise?.fail(HTTPParserError.invalidHeaderToken)
38
0
                context.fireErrorCaught(HTTPParserError.invalidHeaderToken)
39
0
                return
40
0
            }
41
0
        case .body, .end(.none):
42
0
            ()
43
0
        case .end(.some(let trailers)):
44
0
            guard trailers.areValidToSend else {
45
0
                promise?.fail(HTTPParserError.invalidHeaderToken)
46
0
                context.fireErrorCaught(HTTPParserError.invalidHeaderToken)
47
0
                return
48
0
            }
49
0
        }
50
0
51
0
        context.write(data, promise: promise)
52
0
    }
53
54
0
    static func uriOnlyContainsAllowedCharacters(_ uri: String) -> Bool {
55
0
        // The spec in [RFC 9112](https://datatracker.ietf.org/doc/html/rfc9112#section-3.2) defines the valid
56
0
        // characters for the request-target as the following:
57
0
        //
58
0
        // ```
59
0
        // request-target = origin-form / absolute-form / authority-form / asterisk-form
60
0
        //
61
0
        // origin-form    = absolute-path [ "?" query ]
62
0
        // absolute-form  = absolute-URI
63
0
        // authority-form = uri-host ":" port              ; CONNECT only
64
0
        // asterisk-form  = "*"                            ; OPTIONS only
65
0
        // ```
66
0
        //
67
0
        // The component grammar comes from [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986#section-3)
68
0
        // (updated by [RFC 8820](https://datatracker.ietf.org/doc/html/rfc8820), which adds best-practice
69
0
        // guidance for URI design but does not change the syntax):
70
0
        //
71
0
        // ```
72
0
        // absolute-path = 1*( "/" segment )
73
0
        // segment       = *pchar
74
0
        // query         = *( pchar / "/" / "?" )
75
0
        //
76
0
        // pchar         = unreserved / pct-encoded / sub-delims / ":" / "@"
77
0
        // pct-encoded   = "%" HEXDIG HEXDIG
78
0
        //
79
0
        // unreserved    = ALPHA / DIGIT / "-" / "." / "_" / "~"
80
0
        // reserved      = gen-delims / sub-delims
81
0
        // gen-delims    = ":" / "/" / "?" / "#" / "[" / "]" / "@"
82
0
        // sub-delims    = "!" / "$" / "&" / "'" / "(" / ")"
83
0
        //               / "*" / "+" / "," / ";" / "="
84
0
        // ```
85
0
        //
86
0
        // In other words, the literal byte set allowed on the wire is:
87
0
        //
88
0
        // ```
89
0
        //   ALPHA          %x41-5A / %x61-7A      ; A–Z a–z
90
0
        //   DIGIT          %x30-39                ; 0–9
91
0
        //   unreserved     "-" "." "_" "~"
92
0
        //   gen-delims     ":" "/" "?" "#" "[" "]" "@"
93
0
        //   sub-delims     "!" "$" "&" "'" "(" ")" "*" "+" "," ";" "="
94
0
        //   pct-encoded    "%" HEXDIG HEXDIG      ; escape for anything else
95
0
        // ```
96
0
        //
97
0
        // Everything outside this set — SP, CTLs (%x00-1F / %x7F), non-ASCII (%x80-FF),
98
0
        // and `" < > \ ^ ` { | }` — MUST be percent-encoded. Bare CR, LF, or NUL in
99
0
        // the request-target MUST be rejected (request smuggling / response splitting).
100
0
101
0
        uri.utf8.allSatisfy { byte in
102
0
            switch byte {
103
0
            case  // unreserved
104
0
            //   - ALPHA
105
0
            UInt8(ascii: "A")...UInt8(ascii: "Z"), UInt8(ascii: "a")...UInt8(ascii: "z"),
106
0
                //   - DIGIT
107
0
                UInt8(ascii: "0")...UInt8(ascii: "9"),
108
0
                //   - extra characters
109
0
                UInt8(ascii: "-"), UInt8(ascii: "."), UInt8(ascii: "_"), UInt8(ascii: "~"),
110
0
                // gen-delims
111
0
                UInt8(ascii: ":"), UInt8(ascii: "/"), UInt8(ascii: "?"), UInt8(ascii: "#"),
112
0
                UInt8(ascii: "["), UInt8(ascii: "]"), UInt8(ascii: "@"),
113
0
                // sub-delims
114
0
                UInt8(ascii: "!"), UInt8(ascii: "$"), UInt8(ascii: "&"), UInt8(ascii: "'"),
115
0
                UInt8(ascii: "("), UInt8(ascii: ")"), UInt8(ascii: "*"), UInt8(ascii: "+"),
116
0
                UInt8(ascii: ","), UInt8(ascii: ";"), UInt8(ascii: "="),
117
0
                // pct-encoded
118
0
                UInt8(ascii: "%"):
119
0
                return true
120
0
            default:
121
0
                return false
122
0
            }
123
0
        }
124
0
    }
125
}
126
127
/// A ChannelHandler to validate that outbound response headers are spec-compliant.
128
///
129
/// The HTTP RFCs constrain the bytes that are validly present within a HTTP/1.1 header block.
130
/// ``NIOHTTPResponseHeadersValidator`` polices this constraint and ensures that only valid header blocks
131
/// are emitted on the network. If a header block is invalid, then ``NIOHTTPResponseHeadersValidator``
132
/// will send a ``HTTPParserError/invalidHeaderToken``.
133
///
134
/// ``NIOHTTPResponseHeadersValidator`` will also validate that the HTTP trailers are within specification,
135
/// if they are present.
136
public final class NIOHTTPResponseHeadersValidator: ChannelOutboundHandler, RemovableChannelHandler {
137
    public typealias OutboundIn = HTTPServerResponsePart
138
    public typealias OutboundOut = HTTPServerResponsePart
139
140
    private enum State {
141
        /// Validating response parts.
142
        case validating
143
        /// Dropping all response parts. This is a terminal state.
144
        case dropping
145
    }
146
147
    private var state: State
148
149
0
    public init() {
150
0
        self.state = .validating
151
0
    }
152
153
0
    public func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise<Void>?) {
154
0
        switch (NIOHTTPResponseHeadersValidator.unwrapOutboundIn(data), self.state) {
155
0
        case (.head(let head), .validating):
156
0
            if head.headers.areValidToSend, head.status.isValidToSend {
157
0
                context.write(data, promise: promise)
158
0
            } else {
159
0
                self.state = .dropping
160
0
                promise?.fail(HTTPParserError.invalidHeaderToken)
161
0
                context.fireErrorCaught(HTTPParserError.invalidHeaderToken)
162
0
            }
163
0
164
0
        case (.body, .validating):
165
0
            context.write(data, promise: promise)
166
0
167
0
        case (.end(let trailers), .validating):
168
0
            // No trailers are always valid trailers.
169
0
            if trailers?.areValidToSend ?? true {
170
0
                context.write(data, promise: promise)
171
0
            } else {
172
0
                self.state = .dropping
173
0
                promise?.fail(HTTPParserError.invalidHeaderToken)
174
0
                context.fireErrorCaught(HTTPParserError.invalidHeaderToken)
175
0
            }
176
0
177
0
        case (.head, .dropping), (.body, .dropping), (.end, .dropping):
178
0
            promise?.fail(HTTPParserError.invalidHeaderToken)
179
0
        }
180
0
    }
181
}
182
183
@available(*, unavailable)
184
extension NIOHTTPRequestHeadersValidator: Sendable {}
185
186
@available(*, unavailable)
187
extension NIOHTTPResponseHeadersValidator: Sendable {}
188
189
extension HTTPMethod {
190
    /// Whether these HTTPHeaders are valid to send on the wire.
191
0
    var isValidToSend: Bool {
192
0
        switch self {
193
0
        case .GET,
194
0
            .PUT,
195
0
            .ACL,
196
0
            .HEAD,
197
0
            .POST,
198
0
            .COPY,
199
0
            .LOCK,
200
0
            .MOVE,
201
0
            .BIND,
202
0
            .LINK,
203
0
            .PATCH,
204
0
            .TRACE,
205
0
            .MKCOL,
206
0
            .MERGE,
207
0
            .PURGE,
208
0
            .NOTIFY,
209
0
            .SEARCH,
210
0
            .UNLOCK,
211
0
            .REBIND,
212
0
            .UNBIND,
213
0
            .REPORT,
214
0
            .DELETE,
215
0
            .UNLINK,
216
0
            .CONNECT,
217
0
            .MSEARCH,
218
0
            .OPTIONS,
219
0
            .PROPFIND,
220
0
            .CHECKOUT,
221
0
            .PROPPATCH,
222
0
            .SUBSCRIBE,
223
0
            .MKCALENDAR,
224
0
            .MKACTIVITY,
225
0
            .UNSUBSCRIBE,
226
0
            .SOURCE:
227
0
            true
228
0
229
0
        case .RAW(let value):
230
0
            // The spec in [RFC 9110](https://httpwg.org/specs/rfc9110.html#method.overview) defines the valid
231
0
            // characters as the following:
232
0
            //
233
0
            // ```
234
0
            // method = token
235
0
            //
236
0
            // token          = 1*tchar
237
0
            //
238
0
            // tchar          = "!" / "#" / "$" / "%" / "&" / "'" / "*"
239
0
            //                / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
240
0
            //                / DIGIT / ALPHA
241
0
            //                ; any VCHAR, except delimiters
242
0
243
0
            value.utf8.allSatisfy { byte in
244
0
                switch byte {
245
0
                case  // ALPHA
246
0
                UInt8(ascii: "A")...UInt8(ascii: "Z"), UInt8(ascii: "a")...UInt8(ascii: "z"),
247
0
                    // DIGIT
248
0
                    UInt8(ascii: "0")...UInt8(ascii: "9"),
249
0
                    // token
250
0
                    UInt8(ascii: "!"), UInt8(ascii: "#"), UInt8(ascii: "$"), UInt8(ascii: "%"),
251
0
                    UInt8(ascii: "&"), UInt8(ascii: "'"), UInt8(ascii: "*"), UInt8(ascii: "+"),
252
0
                    UInt8(ascii: "-"), UInt8(ascii: "."), UInt8(ascii: "^"), UInt8(ascii: "_"),
253
0
                    UInt8(ascii: "`"), UInt8(ascii: "|"), UInt8(ascii: "~"):
254
0
                    true
255
0
                default:
256
0
                    false
257
0
                }
258
0
            }
259
0
        }
260
0
    }
261
}
262
263
extension HTTPResponseStatus {
264
0
    var isValidToSend: Bool {
265
0
        switch self {
266
0
        case .continue,
267
0
            .switchingProtocols,
268
0
            .processing,
269
0
            .ok,
270
0
            .created,
271
0
            .accepted,
272
0
            .nonAuthoritativeInformation,
273
0
            .noContent,
274
0
            .resetContent,
275
0
            .partialContent,
276
0
            .multiStatus,
277
0
            .alreadyReported,
278
0
            .imUsed,
279
0
            .multipleChoices,
280
0
            .movedPermanently,
281
0
            .found,
282
0
            .seeOther,
283
0
            .notModified,
284
0
            .useProxy,
285
0
            .temporaryRedirect,
286
0
            .permanentRedirect,
287
0
            .badRequest,
288
0
            .unauthorized,
289
0
            .paymentRequired,
290
0
            .forbidden,
291
0
            .notFound,
292
0
            .methodNotAllowed,
293
0
            .notAcceptable,
294
0
            .proxyAuthenticationRequired,
295
0
            .requestTimeout,
296
0
            .conflict,
297
0
            .gone,
298
0
            .lengthRequired,
299
0
            .preconditionFailed,
300
0
            .payloadTooLarge,
301
0
            .uriTooLong,
302
0
            .unsupportedMediaType,
303
0
            .rangeNotSatisfiable,
304
0
            .expectationFailed,
305
0
            .imATeapot,
306
0
            .misdirectedRequest,
307
0
            .unprocessableEntity,
308
0
            .locked,
309
0
            .failedDependency,
310
0
            .upgradeRequired,
311
0
            .preconditionRequired,
312
0
            .tooManyRequests,
313
0
            .requestHeaderFieldsTooLarge,
314
0
            .unavailableForLegalReasons,
315
0
            .internalServerError,
316
0
            .notImplemented,
317
0
            .badGateway,
318
0
            .serviceUnavailable,
319
0
            .gatewayTimeout,
320
0
            .httpVersionNotSupported,
321
0
            .variantAlsoNegotiates,
322
0
            .insufficientStorage,
323
0
            .loopDetected,
324
0
            .notExtended,
325
0
            .networkAuthenticationRequired:
326
0
            true
327
0
328
0
        case .custom(_, let reasonPhrase):
329
0
            // The spec in [RFC 9112](https://datatracker.ietf.org/doc/html/rfc9112#section-4) defines the valid
330
0
            // characters as the following:
331
0
            //
332
0
            // ```
333
0
            // reason-phrase = 1*( HTAB / SP / VCHAR / obs-text )
334
0
            //
335
0
            // obs-text      = %x80-FF
336
0
            // ```
337
0
338
0
            reasonPhrase.utf8.allSatisfy { byte in
339
0
                switch byte {
340
0
                case 9,  // HTAB
341
0
                    32,  // SP
342
0
                    33...126,  // VCHAR
343
0
                    128...255:  // obs-text
344
0
                    return true
345
0
                default:
346
0
                    return false
347
0
                }
348
0
            }
349
0
        }
350
0
    }
351
}