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

lunarmodules / copas / 30401607642

28 Jul 2026 09:40PM UTC coverage: 85.624% (-0.1%) from 85.764%
30401607642

push

github

Tieske
fix(http): do not send literal IP addresses as the TLS SNI name

RFC 6066 forbids IP literals in the SNI extension, but the SNI
fallback used u.host unconditionally, so connecting (directly or via
redirect) to a bare IPv4/IPv6 address sent that address as the SNI
name instead of a real hostname.

Added ishostliteral() to detect IPv4/IPv6 literals and skip the
fallback for them. When no usable name is available, ssl_params.sni
is collapsed to false so copas.lua's connect() omits the SNI
extension entirely rather than send a bogus name.

5 of 8 new or added lines in 1 file covered. (62.5%)

24 existing lines in 6 files now uncovered.

1495 of 1746 relevant lines covered (85.62%)

55712.12 hits per line

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

87.18
/src/copas/http.lua
1
-----------------------------------------------------------------------------
2
-- Full copy of the LuaSocket code, modified to include
3
-- https and http/https redirects, and Copas async enabled.
4
-----------------------------------------------------------------------------
5
-- HTTP/1.1 client support for the Lua language.
6
-- LuaSocket toolkit.
7
-- Author: Diego Nehab
8
-----------------------------------------------------------------------------
9

10
-----------------------------------------------------------------------------
11
-- Declare module and import dependencies
12
-------------------------------------------------------------------------------
13
local socket = require("socket")
30✔
14
local url = require("socket.url")
30✔
15
local ltn12 = require("ltn12")
30✔
16
local mime = require("mime")
30✔
17
local string = require("string")
30✔
18
local headers = require("socket.headers")
30✔
19
local base = _G
30✔
20
local table = require("table")
30✔
21
local copas = require("copas")
30✔
22
copas.http = {}
30✔
23
local _M = copas.http
30✔
24

25
-----------------------------------------------------------------------------
26
-- Program constants
27
-----------------------------------------------------------------------------
28
-- connection timeout in seconds
29
_M.TIMEOUT = 60
30✔
30
-- default port for document retrieval
31
_M.PORT = 80
30✔
32
-- user agent field sent in request
33
_M.USERAGENT = socket._VERSION
30✔
34

35
-- Default settings for SSL
36
_M.SSLPORT = 443
30✔
37
_M.SSLPROTOCOL = "tlsv1_2"
30✔
38
_M.SSLOPTIONS  = "all"
30✔
39
_M.SSLVERIFY   = "none"
30✔
40

41

42
-----------------------------------------------------------------------------
43
-- Reads MIME headers from a connection, unfolding where needed
44
-----------------------------------------------------------------------------
45
local function receiveheaders(sock, headers)
46
    local line, name, value, err
47
    headers = headers or {}
144✔
48
    -- get first line
49
    line, err = sock:receive()
168✔
50
    if err then return nil, err end
144✔
51
    -- headers go until a blank line is found
52
    while line ~= "" do
1,500✔
53
        -- get field-name and value
54
        name, value = socket.skip(2, string.find(line, "^(.-):%s*(.*)"))
1,362✔
55
        if not (name and value) then return nil, "malformed reponse headers" end
1,362✔
56
        name = string.lower(name)
1,589✔
57
        -- get next line (value might be folded)
58
        line, err  = sock:receive()
1,589✔
59
        if err then return nil, err end
1,362✔
60
        -- unfold any folded values
61
        while string.find(line, "^%s") do
1,356✔
UNCOV
62
            value = value .. line
×
63
            line, err = sock:receive()
×
64
            if err then return nil, err end
×
65
        end
66
        -- save pair in table
67
        if headers[name] then headers[name] = headers[name] .. ", " .. value
1,356✔
68
        else headers[name] = value end
1,272✔
69
    end
70
    return headers
138✔
71
end
72

73
-----------------------------------------------------------------------------
74
-- Extra sources and sinks
75
-----------------------------------------------------------------------------
76
socket.sourcet["http-chunked"] = function(sock, headers)
30✔
77
    return base.setmetatable({
66✔
78
        getfd = function() return sock:getfd() end,
36✔
79
        dirty = function() return sock:dirty() end
36✔
80
    }, {
36✔
81
        __call = function()
82
            -- get chunk size, skip extention
83
            local line, err = sock:receive()
200✔
84
            if err then return nil, err end
200✔
85
            local size = base.tonumber(string.gsub(line, ";.*", ""), 16)
200✔
86
            if not size then return nil, "invalid chunk size" end
200✔
87
            -- was it the last chunk?
88
            if size > 0 then
200✔
89
                -- if not, get chunk and skip terminating CRLF
90
                local chunk, err = sock:receive(size)
164✔
91
                if chunk then sock:receive() end
164✔
92
                return chunk, err
164✔
93
            else
94
                -- if it was, read trailers into headers table
95
                headers, err = receiveheaders(sock, headers)
42✔
96
                if not headers then return nil, err end
36✔
97
            end
98
        end
99
    })
24✔
100
end
101

102
socket.sinkt["http-chunked"] = function(sock)
30✔
UNCOV
103
    return base.setmetatable({
×
104
        getfd = function() return sock:getfd() end,
UNCOV
105
        dirty = function() return sock:dirty() end
×
106
    }, {
×
107
        __call = function(self, chunk, err)
UNCOV
108
            if not chunk then return sock:send("0\r\n\r\n") end
×
109
            local size = string.format("%X\r\n", string.len(chunk))
×
110
            return sock:send(size ..  chunk .. "\r\n")
×
111
        end
112
    })
113
end
114

115
-----------------------------------------------------------------------------
116
-- Low level HTTP API
117
-----------------------------------------------------------------------------
118
local metat = { __index = {} }
30✔
119

120
function _M.open(reqt)
30✔
121
    -- create socket with user connect function
122
    local c = socket.try(reqt:create())   -- method call, passing reqt table as self!
160✔
123
    local h = base.setmetatable({ c = c }, metat)
120✔
124
    -- create finalized try
125
    h.try = socket.newtry(function() h:close() end)
168✔
126
    -- set timeout before connecting
127
    local to = reqt.timeout or _M.TIMEOUT
120✔
128
    if type(to) == "table" then
120✔
UNCOV
129
      h.try(c:settimeouts(
×
130
        to.connect or _M.TIMEOUT,
×
131
        to.send or _M.TIMEOUT,
×
132
        to.receive or _M.TIMEOUT))
×
133
    else
134
      h.try(c:settimeout(to))
140✔
135
    end
136
    h.try(c:connect(reqt.host, reqt.port or _M.PORT))
140✔
137
    -- here everything worked
138
    return h
120✔
139
end
140

141
function metat.__index:sendrequestline(method, uri)
60✔
142
    local reqline = string.format("%s %s HTTP/1.1\r\n", method or "GET", uri)
120✔
143
    return self.try(self.c:send(reqline))
140✔
144
end
145

146
function metat.__index:sendheaders(tosend)
60✔
147
    local canonic = headers.canonic
120✔
148
    local h = "\r\n"
120✔
149
    for f, v in base.pairs(tosend) do
810✔
150
        h = (canonic[f] or f) .. ": " .. v .. "\r\n" .. h
690✔
151
    end
152
    self.try(self.c:send(h))
140✔
153
    return 1
120✔
154
end
155

156
function metat.__index:sendbody(headers, source, step)
60✔
157
    source = source or ltn12.source.empty()
30✔
158
    step = step or ltn12.pump.step
30✔
159
    -- if we don't know the size in advance, send chunked and hope for the best
160
    local mode = "http-chunked"
30✔
161
    if headers["content-length"] then mode = "keep-open" end
30✔
162
    return self.try(ltn12.pump.all(source, socket.sink(mode, self.c), step))
40✔
163
end
164

165
function metat.__index:receivestatusline()
60✔
166
    local status = self.try(self.c:receive(5))
140✔
167
    -- identify HTTP/0.9 responses, which do not contain a status line
168
    -- this is just a heuristic, but is what the RFC recommends
169
    if status ~= "HTTP/" then return nil, status end
108✔
170
    -- otherwise proceed reading a status line
171
    status = self.try(self.c:receive("*l", status))
144✔
172
    local code = socket.skip(2, string.find(status, "HTTP/%d*%.%d* (%d%d%d)"))
108✔
173
    return self.try(base.tonumber(code), status)
108✔
174
end
175

176
function metat.__index:receiveheaders()
60✔
177
    return self.try(receiveheaders(self.c))
126✔
178
end
179

180
function metat.__index:receivebody(headers, sink, step)
60✔
181
    sink = sink or ltn12.sink.null()
48✔
182
    step = step or ltn12.pump.step
48✔
183
    local length = base.tonumber(headers["content-length"])
48✔
184
    local t = headers["transfer-encoding"] -- shortcut
48✔
185
    local mode = "default" -- connection close
48✔
186
    if t and t ~= "identity" then mode = "http-chunked"
48✔
187
    elseif base.tonumber(headers["content-length"]) then mode = "by-length" end
12✔
188
    return self.try(ltn12.pump.all(socket.source(mode, self.c, length),
96✔
189
        sink, step))
56✔
190
end
191

192
function metat.__index:receive09body(status, sink, step)
60✔
UNCOV
193
    local source = ltn12.source.rewind(socket.source("until-closed", self.c))
×
194
    source(status)
×
195
    return self.try(ltn12.pump.all(source, sink, step))
×
196
end
197

198
function metat.__index:close()
60✔
199
    return self.c:close()
120✔
200
end
201

202
-----------------------------------------------------------------------------
203
-- High level HTTP API
204
-----------------------------------------------------------------------------
205
local function adjusturi(reqt)
206
    local u = reqt
138✔
207
    -- if there is a proxy, we need the full url. otherwise, just a part.
208
    if not reqt.proxy and not _M.PROXY then
138✔
209
        u = {
138✔
210
           path = socket.try(reqt.path, "invalid path 'nil'"),
161✔
211
           params = reqt.params,
138✔
212
           query = reqt.query,
138✔
213
           fragment = reqt.fragment
138✔
214
        }
138✔
215
    end
216
    return url.build(u)
138✔
217
end
218

219
local function adjustproxy(reqt)
220
    local proxy = reqt.proxy or _M.PROXY
138✔
221
    if proxy then
138✔
UNCOV
222
        proxy = url.parse(proxy)
×
223
        return proxy.host, proxy.port or 3128
×
224
    else
225
        return reqt.host, reqt.port
138✔
226
    end
227
end
228

229
local function adjustheaders(reqt)
230
    -- default headers
231
    local host = string.gsub(reqt.authority, "^.-@", "")
138✔
232
    local lower = {
138✔
233
        ["user-agent"] = _M.USERAGENT,
138✔
234
        ["host"] = host,
138✔
235
        ["connection"] = "close, TE",
115✔
236
        ["te"] = "trailers"
115✔
237
    }
238
    -- if we have authentication information, pass it along
239
    if reqt.user and reqt.password then
138✔
UNCOV
240
        lower["authorization"] =
×
241
            "Basic " ..  (mime.b64(reqt.user .. ":" .. reqt.password))
×
242
    end
243
    -- override with user headers
244
    for i,v in base.pairs(reqt.headers or lower) do
780✔
245
        lower[string.lower(i)] = v
749✔
246
    end
247
    return lower
138✔
248
end
249

250
-- default url parts
251
local default = {
30✔
252
    host = "",
25✔
253
    port = _M.PORT,
30✔
254
    path ="/",
25✔
255
    scheme = "http"
25✔
256
}
257

258
local function adjustrequest(reqt)
259
    -- parse url if provided
260
    local nreqt = reqt.url and url.parse(reqt.url, default) or {}
175✔
261
    -- explicit components override url
262
    for i,v in base.pairs(reqt) do nreqt[i] = v end
858✔
263
    socket.try(base.type(nreqt.scheme) == "string",
300✔
264
        "invalid scheme '" .. base.tostring(nreqt.scheme) .. "'")
150✔
265
    nreqt.scheme = string.lower(nreqt.scheme)
168✔
266
    socket.try(nreqt.scheme == "http" or nreqt.scheme == "https",
288✔
267
        "unsupported scheme '" .. nreqt.scheme .. "'")
144✔
268
    if nreqt.port == "" then nreqt.port = 80 end
138✔
269
    socket.try(nreqt.host and nreqt.host ~= "",
276✔
270
        "invalid host '" .. base.tostring(nreqt.host) .. "'")
138✔
271
    -- compute uri if user hasn't overriden
272
    nreqt.uri = reqt.uri or adjusturi(nreqt)
161✔
273
    -- ajust host and port if there is a proxy
274
    nreqt.host, nreqt.port = adjustproxy(nreqt)
161✔
275
    -- adjust headers in request
276
    nreqt.headers = adjustheaders(nreqt)
161✔
277
    return nreqt
138✔
278
end
279

280
local function shouldredirect(reqt, code, headers)
281
    return headers.location and
102✔
282
           string.gsub(headers.location, "%s", "") ~= "" and
54✔
283
           (reqt.redirect ~= false) and
54✔
284
           (code == 301 or code == 302 or code == 303 or code == 307) and
54✔
285
           (not reqt.method or reqt.method == "GET" or reqt.method == "HEAD")
54✔
286
           and (not reqt.nredirects or reqt.nredirects < 5)
102✔
287
end
288

289
local function shouldreceivebody(reqt, code)
290
    if reqt.method == "HEAD" then return nil end
48✔
291
    if code == 204 or code == 304 then return nil end
48✔
292
    if code >= 100 and code < 200 then return nil end
48✔
293
    return 1
48✔
294
end
295

296
-- forward declarations
297
local trequest, tredirect
298

299
--[[local]] function tredirect(reqt, location)
25✔
300
    local result, code, headers, status = trequest {
108✔
301
        -- the RFC says the redirect URL has to be absolute, but some
302
        -- servers do not respect that
303
        url = url.absolute(reqt.url, location),
63✔
304
        source = reqt.source,
54✔
305
        sink = reqt.sink,
54✔
306
        headers = reqt.headers,
54✔
307
        proxy = reqt.proxy,
54✔
308
        nredirects = (reqt.nredirects or 0) + 1,
54✔
309
        create = reqt.create,
54✔
310
        timeout = reqt.timeout,
54✔
311
    }
312
    -- pass location header back as a hint we redirected
313
    headers = headers or {}
48✔
314
    headers.location = headers.location or location
48✔
315
    return result, code, headers, status
48✔
316
end
317

318
--[[local]] function trequest(reqt)
25✔
319
    -- we loop until we get what we want, or
320
    -- until we are sure there is no way to get it
321
    local nreqt = adjustrequest(reqt)
150✔
322
    local h = _M.open(nreqt)
138✔
323
    -- send request line and headers
324
    h:sendrequestline(nreqt.method, nreqt.uri)
120✔
325
    h:sendheaders(nreqt.headers)
120✔
326
    -- if there is a body, send it
327
    if nreqt.source then
120✔
328
        h:sendbody(nreqt.headers, nreqt.source, nreqt.step)
30✔
329
    end
330
    local code, status = h:receivestatusline()
120✔
331
    -- if it is an HTTP/0.9 server, simply get the body and we are done
332
    if not code then
108✔
UNCOV
333
        h:receive09body(status, nreqt.sink, nreqt.step)
×
334
        return 1, 200
×
335
    end
336
    local headers
337
    -- ignore any 100-continue messages
338
    while code == 100 do
108✔
UNCOV
339
        h:receiveheaders()
×
340
        code, status = h:receivestatusline()
×
341
    end
342
    headers = h:receiveheaders()
125✔
343
    -- at this point we should have a honest reply from the server
344
    -- we can't redirect if we already used the source, so we report the error
345
    if shouldredirect(nreqt, code, headers) and not nreqt.source then
119✔
346
        h:close()
54✔
347
        return tredirect(reqt, headers.location)
54✔
348
    end
349
    -- here we are finally done
350
    if shouldreceivebody(nreqt, code) then
56✔
351
        h:receivebody(headers, nreqt.sink, nreqt.step)
48✔
352
    end
353
    h:close()
42✔
354
    return 1, code, headers, status
42✔
355
end
356

357
-- Returns true if `host` is a literal IPv4 or IPv6 address rather than a DNS
358
-- hostname. RFC 6066 disallows IP literals in the TLS SNI extension, so these
359
-- must not be sent as an SNI name.
360
local function ishostliteral(host)
361
  if string.find(host, ":", 1, true) then
66✔
NEW
362
    return true -- IPv6 addresses always contain a colon (socket.url strips the brackets)
×
363
  end
364
  return string.match(host, "^%d+%.%d+%.%d+%.%d+$") ~= nil
66✔
365
end
366

367
-- Return a function which creates a tcp socket that will
368
-- include the optional SSL/TLS connection, and unsafe redirect checks
369
function _M.getcreatefunc(params)
30✔
370
   params = params or {}
84✔
371
   local ssl_params = params.sslparams or {}
84✔
372
   ssl_params.wrap = ssl_params.wrap or {
84✔
373
      -- backward compatibility
374
      protocol = params.protocol,
84✔
375
      options = params.options,
84✔
376
      verify = params.verify,
84✔
377
   }
84✔
378
   ssl_params.sni = ssl_params.sni or {}
84✔
379

380
   -- Default settings
381
   ssl_params.wrap.protocol = ssl_params.wrap.protocol or _M.SSLPROTOCOL
84✔
382
   ssl_params.wrap.options = ssl_params.wrap.options or _M.SSLOPTIONS
84✔
383
   if ssl_params.wrap.verify == nil then
84✔
384
      ssl_params.wrap.verify = _M.SSLVERIFY
84✔
385
   end
386
   ssl_params.wrap.mode = "client"   -- Force client mode
84✔
387

388
   local sni_name = ssl_params.sni.names
84✔
389
   if not sni_name then
84✔
390
      -- name hasn't been set, and hence will be set below. Since this alters
391
      -- the table, we must make a copy. Otherwise the altered table might be
392
      -- reused if a redirect is encountered.
393
      local old_params = ssl_params
84✔
394
      ssl_params = {}
84✔
395
      for k,v in pairs(old_params) do
252✔
396
        ssl_params[k] = v
168✔
397
      end
398
      ssl_params.sni = {}
84✔
399
   end
400

401
   -- upvalue to track https -> http redirection
402
   local washttps = false
84✔
403
   local first_request = true -- on follow up redirects we must clear sni-name
84✔
404

405
   -- 'create' function for LuaSocket
406
   return function (reqt)
407
      local u = url.parse(reqt.url)
138✔
408
      if (reqt.scheme or u.scheme) == "https" then
138✔
409
        if type(ssl_params.sni) ~= "table" then
66✔
NEW
410
          ssl_params.sni = {}  -- was collapsed to `false` below on a prior IP-literal hop
×
411
        end
412
        if first_request then
66✔
413
          ssl_params.sni.names = sni_name    -- set SNI name to the given name
36✔
414
          first_request = false
36✔
415
        else
416
          ssl_params.sni.names = nil         -- clear SNI name for follow up redirects
30✔
417
        end
418
        if not ssl_params.sni.names and not ishostliteral(u.host) then
77✔
419
          ssl_params.sni.names = u.host
66✔
420
        end
421
        if not ssl_params.sni.names then
66✔
422
          -- no hostname to send: u.host is an IP literal, which RFC 6066 forbids
423
          -- in the SNI extension, so omit it entirely rather than send a bogus name
NEW
424
          ssl_params.sni = false
×
425
        end
426
        -- https, provide an ssl wrapped socket
427
        local conn = copas.wrap(socket.tcp(), ssl_params)
68✔
428
        -- insert https default port, overriding http port inserted by LuaSocket
429
        if not u.port then
66✔
430
           u.port = _M.SSLPORT
66✔
431
           reqt.url = url.build(u)
77✔
432
           reqt.port = _M.SSLPORT
66✔
433
        end
434
        washttps = true
66✔
435
        return conn
66✔
436
      else
437
        -- regular http, needs just a socket...
438
        if washttps and params.redirect ~= "all" then
72✔
439
          socket.try(nil, "Unallowed insecure redirect https to http")
6✔
440
        end
441
        return copas.wrap(socket.tcp())
66✔
442
      end
443
   end
444
end
445

446
-- parses a shorthand form into the advanced table form.
447
-- adds field `target` to the table. This will hold the return values.
448
_M.parseRequest = function(u, b)
449
    local reqt = {
12✔
450
        url = u,
12✔
451
        target = {},
12✔
452
    }
453
    reqt.sink = ltn12.sink.table(reqt.target)
14✔
454
    if b then
12✔
UNCOV
455
        reqt.source = ltn12.source.string(b)
×
UNCOV
456
        reqt.headers = {
×
457
            ["content-length"] = string.len(b),
UNCOV
458
            ["content-type"] = "application/x-www-form-urlencoded"
×
459
        }
UNCOV
460
        reqt.method = "POST"
×
461
    end
462
    return reqt
12✔
463
end
464

465
_M.request = socket.protect(function(reqt, body)
60✔
466
    if base.type(reqt) == "string" then
108✔
467
        reqt = _M.parseRequest(reqt, body)
14✔
468
        local ok, code, headers, status = _M.request(reqt)
12✔
469

470
        if ok then
12✔
471
            return table.concat(reqt.target), code, headers, status
12✔
472
        else
473
            return nil, code
×
474
        end
475
    else
476
        -- strict check on timeout table to prevent typo's from going unnoticed
477
        if type(reqt.timeout) == "table" then
96✔
UNCOV
478
          local allowed = { connect = true, send = true, receive = true }
×
UNCOV
479
          for k in pairs(reqt.timeout) do
×
UNCOV
480
            assert(allowed[k], "'"..tostring(k).."' is not a valid timeout option. Valid: 'connect', 'send', 'receive'")
×
481
          end
482
        end
483
        reqt.create = reqt.create or _M.getcreatefunc(reqt)
108✔
484
        return trequest(reqt)
96✔
485
    end
486
end)
487

488
return _M
30✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc