about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--src/config/config.nim5
-rw-r--r--src/config/cookie.nim25
-rw-r--r--src/local/container.nim2
-rw-r--r--src/local/pager.nim99
-rw-r--r--src/server/buffer.nim1
-rw-r--r--src/server/connecterror.nim2
-rw-r--r--src/server/loader.nim214
-rw-r--r--src/server/loaderiface.nim3
-rw-r--r--test/net/cookie.css.http4
-rw-r--r--test/net/cookie.http4
-rwxr-xr-xtest/net/run.sh5
11 files changed, 253 insertions, 111 deletions
diff --git a/src/config/config.nim b/src/config/config.nim
index f587754c..972ff0f0 100644
--- a/src/config/config.nim
+++ b/src/config/config.nim
@@ -66,11 +66,6 @@ type
     frtData = "data"
     frtMailto = "mailto"
 
-  CookieMode* = enum
-    cmNone = "false"
-    cmReadOnly = "true"
-    cmSave = "save"
-
   SiteConfig* = ref object
     url*: Option[Regex]
     host*: Option[Regex]
diff --git a/src/config/cookie.nim b/src/config/cookie.nim
index dacbfa0f..60772b3a 100644
--- a/src/config/cookie.nim
+++ b/src/config/cookie.nim
@@ -25,12 +25,18 @@ type
     skip: bool
 
   CookieJar* = ref object
+    name*: string
     cookies*: seq[Cookie]
     map: Table[string, Cookie] # {host}{path}\t{name}
 
   CookieJarMap* = ref object
     mtime: int64
-    jars*: OrderedTable[string, CookieJar]
+    jars: OrderedTable[cstring, CookieJar]
+
+  CookieMode* = enum
+    cmNone = "false"
+    cmReadOnly = "true"
+    cmSave = "save"
 
 proc newCookieJarMap*(): CookieJarMap =
   return CookieJarMap()
@@ -38,6 +44,14 @@ proc newCookieJarMap*(): CookieJarMap =
 proc newCookieJar*(): CookieJar =
   return CookieJar()
 
+proc addNew*(map: CookieJarMap; name: sink string): CookieJar =
+  let jar = CookieJar(name: name)
+  map.jars[cstring(jar.name)] = jar
+  return jar
+
+proc getOrDefault*(map: CookieJarMap; name: string): CookieJar =
+  return map.jars.getOrDefault(cstring(name))
+
 proc parseCookieDate(val: string): Option[int64] =
   # cookie-date
   const Delimiters = {'\t', ' '..'/', ';'..'@', '['..'`', '{'..'~'}
@@ -320,10 +334,9 @@ proc parse(map: CookieJarMap; iq: openArray[char]; warnings: var seq[string]) =
       if domain[0] == '.':
         domain.delete(0..0)
       cookie.domain = domain
-    cookieJar = map.jars.getOrDefault(domain)
+    cookieJar = map.getOrDefault(domain)
     if cookieJar == nil:
-      cookieJar = CookieJar()
-      map.jars[domain] = cookieJar
+      cookieJar = map.addNew(domain)
     cookie.hostOnly = not state.nextBool(iq)
     cookie.path = state.nextField(iq)
     cookie.secure = state.nextBool(iq)
@@ -386,8 +399,8 @@ proc write*(map: CookieJarMap; file: string): bool =
             buf.setLen(0)
           if cookie.httpOnly:
             buf &= "#HttpOnly_"
-          if cookie.domain != name:
-            buf &= name & "@"
+          if cstring(cookie.domain) != name:
+            buf &= $name & "@"
           if not cookie.hostOnly:
             buf &= '.'
           buf &= cookie.domain & '\t'
diff --git a/src/local/container.nim b/src/local/container.nim
index 3b25f14f..c4f20523 100644
--- a/src/local/container.nim
+++ b/src/local/container.nim
@@ -1543,7 +1543,7 @@ proc applyResponse*(container: Container; response: Response;
   let cookieJar = container.loaderConfig.cookieJar
   if cookieJar != nil:
     cookieJar.setCookie(response.headers.getAllNoComma("Set-Cookie"),
-      response.url, container.config.cookieMode == cmSave)
+      response.url, container.loaderConfig.cookieMode == cmSave)
   # set referrer policy, if any
   let referrerPolicy = response.getReferrerPolicy()
   if container.config.refererFrom:
diff --git a/src/local/pager.nim b/src/local/pager.nim
index d7264d86..f8073e58 100644
--- a/src/local/pager.nim
+++ b/src/local/pager.nim
@@ -429,6 +429,80 @@ proc evalJSFree(opaque: RootRef; src, filename: string) =
   let pager = Pager(opaque)
   JS_FreeValue(pager.jsctx, pager.evalJS(src, filename))
 
+type CookieStreamOpaque = ref object of RootObj
+  pager: Pager
+  buffer: string
+
+proc onReadCookieStream(response: Response) =
+  const BufferSize = 4096
+  let opaque = CookieStreamOpaque(response.opaque)
+  let pager = opaque.pager
+  while true:
+    let olen = opaque.buffer.len
+    opaque.buffer.setLen(olen + BufferSize)
+    let n = response.body.readData(addr opaque.buffer[olen], BufferSize)
+    if n <= 0:
+      opaque.buffer.setLen(olen)
+      break
+    opaque.buffer.setLen(olen + n)
+  var lastlf = opaque.buffer.rfind('\n')
+  var i = 0
+  # Syntax: {jarId} RS {url} RS {persist?} RS {header} [ CR {header} ... ] LF
+  # Persist is ASCII digit 0 if persist, 1 if not.
+  const RS = '\x1E' # ASCII record separator
+  while i < lastlf:
+    let jarId = opaque.buffer.until(RS, i)
+    i += jarId.len + 1
+    let urls = opaque.buffer.until(RS, i)
+    i += urls.len + 1
+    let persists = opaque.buffer.until(RS, i)
+    i += persists.len + 1
+    var headers: seq[string] = @[]
+    while i - 1 < opaque.buffer.len and opaque.buffer[i - 1] != '\n':
+      let header = opaque.buffer.until({'\n', '\r'}, i)
+      headers.add(header)
+      i += header.len + 1
+    let cookieJar = pager.cookieJars.getOrDefault(jarId)
+    let url = parseURL(urls)
+    let persist = persists != "0"
+    if cookieJar == nil or url.isNone or persist and persists != "1":
+      pager.alert("Error: received wrong set-cookie notification")
+      continue
+    cookieJar.setCookie(headers, url.get, persist)
+  if i > 0:
+    opaque.buffer.delete(0 ..< i)
+
+proc onFinishCookieStream(response: Response; success: bool) =
+  let pager = CookieStreamOpaque(response.opaque).pager
+  pager.alert("Error: cookie stream broken")
+
+proc initLoader(pager: Pager) =
+  let clientConfig = LoaderClientConfig(
+    defaultHeaders: newHeaders(hgRequest, pager.config.network.defaultHeaders),
+    proxy: pager.config.network.proxy,
+    filter: newURLFilter(default = true),
+  )
+  let loader = pager.loader
+  discard loader.addClient(loader.clientPid, clientConfig, -1, isPager = true)
+  pager.loader.registerFun = proc(fd: int) =
+    pager.pollData.register(fd, POLLIN)
+  pager.loader.unregisterFun = proc(fd: int) =
+    pager.pollData.unregister(fd)
+  let request = newRequest(newURL("about:cookie-stream").get)
+  loader.fetch(request).then(proc(res: JSResult[Response]) =
+    if res.isNone:
+      pager.alert("failed to open cookie stream")
+      return
+    # ugly hack, so that the cookie stream does not keep headless
+    # instances running
+    dec loader.mapFds
+    let response = res.get
+    response.opaque = CookieStreamOpaque(pager: pager)
+    response.onRead = onReadCookieStream
+    response.onFinish = onFinishCookieStream
+    response.resume()
+  )
+
 proc newPager*(config: Config; forkserver: ForkServer; ctx: JSContext;
     alerts: seq[string]; loader: FileLoader; loaderPid: int): Pager =
   let pager = Pager(
@@ -448,13 +522,7 @@ proc newPager*(config: Config; forkserver: ForkServer; ctx: JSContext;
   pager.timeouts = newTimeoutState(pager.jsctx, evalJSFree, pager)
   JS_SetModuleLoaderFunc(pager.jsrt, normalizeModuleName, loadJSModule, nil)
   JS_SetInterruptHandler(pager.jsrt, interruptHandler, nil)
-  let clientConfig = LoaderClientConfig(
-    defaultHeaders: newHeaders(hgRequest, pager.config.network.defaultHeaders),
-    proxy: pager.config.network.proxy,
-    filter: newURLFilter(default = true),
-  )
-  discard pager.loader.addClient(pager.loader.clientPid,
-    clientConfig, -1, isPager = true)
+  pager.initLoader()
   block history:
     let hist = newHistory(pager.config.external.historySize, getTime().toUnix())
     let ps = newPosixStream(pager.config.external.historyFile)
@@ -808,10 +876,6 @@ proc run*(pager: Pager; pages: openArray[string]; contentType: string;
     if istream == nil:
       pager.config.start.headless = hmDump
   pager.pollData.register(pager.forkserver.estream.fd, POLLIN)
-  pager.loader.registerFun = proc(fd: int) =
-    pager.pollData.register(fd, POLLIN)
-  pager.loader.unregisterFun = proc(fd: int) =
-    pager.pollData.unregister(fd)
   case pager.term.start(istream)
   of tsrSuccess: discard
   of tsrDA1Fail:
@@ -841,8 +905,6 @@ proc run*(pager: Pager; pages: openArray[string]; contentType: string;
   if pager.config.start.headless == hmFalse:
     pager.inputLoop()
   else:
-    if pager.config.start.headless == hmTrue: # else just dump
-      pager.headlessLoop()
     pager.dumpBuffers()
 
 # Note: this function does not work correctly if start < x of last written char
@@ -1783,7 +1845,6 @@ proc applySiteconf(pager: Pager; url: URL; charsetOverride: Charset;
     charsetOverride: charsetOverride,
     protocol: pager.config.protocol,
     metaRefresh: pager.config.buffer.metaRefresh,
-    cookieMode: pager.config.buffer.cookie,
     markLinks: pager.config.buffer.markLinks
   )
   result.userStyle &= string(pager.config.buffer.userStyle) & '\n'
@@ -1797,6 +1858,7 @@ proc applySiteconf(pager: Pager; url: URL; charsetOverride: Charset;
       allowschemes = @["data", "cache", "stream"],
       default = true
     ),
+    cookieMode: pager.config.buffer.cookie,
     insecureSslNoVerify: false
   )
   var cookieJarId = url.host
@@ -1827,7 +1889,7 @@ proc applySiteconf(pager: Pager; url: URL; charsetOverride: Charset;
         ourl = tmpUrl
         return
     if sc.cookie.isSome:
-      result.cookieMode = sc.cookie.get
+      loaderConfig.cookieMode = sc.cookie.get
     if sc.shareCookieJar.isSome:
       cookieJarId = sc.shareCookieJar.get
     if sc.scripting.isSome:
@@ -1863,11 +1925,10 @@ proc applySiteconf(pager: Pager; url: URL; charsetOverride: Charset;
   if result.images:
     result.imageTypes = pager.config.external.mimeTypes.image
   result.userAgent = loaderConfig.defaultHeaders.getOrDefault("User-Agent")
-  if result.cookieMode != cmNone:
-    var cookieJar = pager.cookieJars.jars.getOrDefault(cookieJarId)
+  if loaderConfig.cookieMode != cmNone:
+    var cookieJar = pager.cookieJars.getOrDefault(cookieJarId)
     if cookieJar == nil:
-      cookieJar = newCookieJar()
-      pager.cookieJars.jars[cookieJarId] = cookieJar
+      cookieJar = pager.cookieJars.addNew(cookieJarId)
     loaderConfig.cookieJar = cookieJar
 
 # Load request in a new buffer.
diff --git a/src/server/buffer.nim b/src/server/buffer.nim
index 489075cf..c36caecf 100644
--- a/src/server/buffer.nim
+++ b/src/server/buffer.nim
@@ -133,7 +133,6 @@ type
     markLinks*: bool
     charsetOverride*: Charset
     metaRefresh*: MetaRefresh
-    cookieMode*: CookieMode
     charsets*: seq[Charset]
     protocol*: Table[string, ProtocolConfig]
     imageTypes*: Table[string, string]
diff --git a/src/server/connecterror.nim b/src/server/connecterror.nim
index 41e4c89e..dff4b12d 100644
--- a/src/server/connecterror.nim
+++ b/src/server/connecterror.nim
@@ -1,4 +1,5 @@
 type ConnectionError* = enum
+  ceCookieStreamExists = -18
   ceCGICachedBodyUnavailable = -17
   ceCGIOutputHandleNotFound = -16
   ceCGIFailedToOpenCacheOutput = -15
@@ -30,6 +31,7 @@ type ConnectionError* = enum
   ceProxyInvalidResponse = (11, "ProxyInvalidResponse")
 
 const ErrorMessages* = [
+  ceCookieStreamExists: "cookie stream already exists",
   ceCGICachedBodyUnavailable: "request body is not ready in the cache",
   ceCGIOutputHandleNotFound: "request body output handle not found",
   ceCGIFailedToOpenCacheOutput: "failed to open cache output",
diff --git a/src/server/loader.nim b/src/server/loader.nim
index 6f9d881f..dcd6d407 100644
--- a/src/server/loader.nim
+++ b/src/server/loader.nim
@@ -29,6 +29,7 @@ import std/strutils
 import std/tables
 import std/times
 
+import config/config
 import config/cookie
 import config/urimethodmap
 import html/script
@@ -71,8 +72,7 @@ type
   LoaderHandle = ref object of RootObj
     registered: bool # track registered state
     stream: PosixStream # input/output stream depending on type
-    when defined(debug):
-      url: URL
+    url: URL # URL nominally retrieved by handle before rewrites
 
   InputHandle = ref object of LoaderHandle
     outputs: seq[OutputHandle] # list of outputs to be streamed into
@@ -80,6 +80,7 @@ type
     cacheRef: CachedItem # if this is a tocache handle, a ref to our cache item
     parser: HeaderParser # only exists for CGI handles
     rstate: ResponseState # track response state
+    credentials: bool # normalized to "include" (true) or "omit" (false)
     contentLen: uint64 # value of Content-Length; uint64.high if no such header
     bytesSeen: uint64 # number of bytes read until now
     startTime: Time # time when download of the body was started
@@ -89,7 +90,7 @@ type
     currentBuffer: LoaderBuffer
     currentBufferIdx: int
     buffers: Deque[LoaderBuffer]
-    ownerPid: int
+    owner: ClientHandle
     outputId: int
     istreamAtEnd: bool
     suspended: bool
@@ -153,6 +154,7 @@ type
     unregWrite: seq[OutputHandle]
     unregClient: seq[ClientHandle]
     downloadList: seq[DownloadItem]
+    cookieStream: OutputHandle
 
   LoaderConfig* = object
     cgiDir*: seq[string]
@@ -192,14 +194,21 @@ proc getOutputId(ctx: var LoaderContext): int =
   inc ctx.outputNum
 
 # Create a new loader handle, with the output stream ostream.
-proc newInputHandle(ctx: var LoaderContext; ostream: PosixStream; pid: int;
-    suspended = true): InputHandle =
-  let handle = InputHandle(cacheId: -1, contentLen: uint64.high)
+proc newInputHandle(ctx: var LoaderContext; ostream: PosixStream;
+    owner: ClientHandle; url: URL; credentials: bool; suspended = true):
+    InputHandle =
+  let handle = InputHandle(
+    cacheId: -1,
+    contentLen: uint64.high,
+    url: url,
+    credentials: credentials
+  )
   let output = OutputHandle(
     stream: ostream,
     parent: handle,
     outputId: ctx.getOutputId(),
-    ownerPid: pid,
+    owner: owner,
+    url: url,
     suspended: suspended
   )
   ctx.put(output)
@@ -215,6 +224,12 @@ template isEmpty(output: OutputHandle): bool =
 proc newLoaderBuffer(size = LoaderBufferPageSize): LoaderBuffer =
   return LoaderBuffer(page: newSeqUninitialized[uint8](size))
 
+proc newLoaderBuffer(s: openArray[char]): LoaderBuffer =
+  let buffer = newLoaderBuffer(s.len)
+  buffer.len = s.len
+  copyMem(addr buffer.page[0], unsafeAddr s[0], s.len)
+  buffer
+
 proc bufferCleared(output: OutputHandle) =
   assert output.currentBuffer != nil
   output.currentBufferIdx = 0
@@ -224,7 +239,7 @@ proc bufferCleared(output: OutputHandle) =
     output.currentBuffer = nil
 
 proc tee(ctx: var LoaderContext; outputIn: OutputHandle; ostream: PosixStream;
-    pid: int): OutputHandle =
+    owner: ClientHandle): OutputHandle =
   assert outputIn.suspended
   let output = OutputHandle(
     parent: outputIn.parent,
@@ -234,12 +249,11 @@ proc tee(ctx: var LoaderContext; outputIn: OutputHandle; ostream: PosixStream;
     buffers: outputIn.buffers,
     istreamAtEnd: outputIn.istreamAtEnd,
     outputId: ctx.getOutputId(),
-    ownerPid: pid,
-    suspended: outputIn.suspended
+    owner: owner,
+    suspended: outputIn.suspended,
+    url: outputIn.url
   )
   ctx.put(output)
-  when defined(debug):
-    output.url = outputIn.url
   if outputIn.parent != nil:
     assert outputIn.parent.parser == nil
     outputIn.parent.outputs.add(output)
@@ -270,6 +284,23 @@ proc sendResult(ctx: var LoaderContext; handle: InputHandle; res: int;
       w.swrite(msg)
   return ctx.pushBuffer(output, buffer, ignoreSuspension = true)
 
+proc updateCookies(ctx: var LoaderContext; cookieJar: CookieJar;
+    url: URL; owner: ClientHandle; values: openArray[string]) =
+  # Syntax: {jarId} RS {url} RS {persist?} RS {header} [ CR {header} ... ] LF
+  # Persist is ASCII digit 0 if persist, 1 if not.
+  const RS = '\x1E' # ASCII record separator
+  let persist = if owner.config.cookieMode == cmSave: '1' else: '0'
+  var s = cookieJar.name & RS & $url & RS & persist & RS
+  for i, it in values.mypairs:
+    s &= it & [false: '\r', true: '\n'][i == values.high]
+  let buffer = newLoaderBuffer(s)
+  case ctx.pushBuffer(ctx.cookieStream, buffer, ignoreSuspension = false)
+  of pbrDone: discard
+  of pbrUnregister:
+    ctx.unregWrite.add(ctx.cookieStream)
+    ctx.cookieStream.dead = true
+    ctx.cookieStream = nil
+
 proc sendStatus(ctx: var LoaderContext; handle: InputHandle; status: uint16;
     headers: Headers): PushBufferResult =
   assert handle.rstate == rsBeforeStatus
@@ -277,10 +308,19 @@ proc sendStatus(ctx: var LoaderContext; handle: InputHandle; status: uint16;
   let contentLens = headers.getOrDefault("Content-Length")
   handle.startTime = getTime()
   handle.contentLen = parseUInt64(contentLens).get(uint64.high)
+  let output = handle.output
+  let cookieJar = output.owner.config.cookieJar
+  if cookieJar != nil and handle.credentials:
+    # Never persist in loader; we save cookies in the pager.
+    let values = headers.getAllNoComma("Set-Cookie")
+    if values.len > 0:
+      cookieJar.setCookie(values, handle.url, persist = false)
+      if ctx.cookieStream != nil:
+        ctx.updateCookies(cookieJar, handle.url, output.owner, values)
   let buffer = bufferFromWriter w:
     w.swrite(status)
     w.swrite(headers)
-  return ctx.pushBuffer(handle.output, buffer, ignoreSuspension = true)
+  return ctx.pushBuffer(output, buffer, ignoreSuspension = true)
 
 proc writeData(ps: PosixStream; buffer: LoaderBuffer; si = 0): int {.inline.} =
   assert buffer.len - si > 0
@@ -350,7 +390,7 @@ func findOutput(ctx: var LoaderContext; id: int;
   for it in ctx.outputHandles:
     if it.outputId == id:
       # verify that it's safe to access this handle.
-      doAssert ctx.isPrivileged(client) or client.pid == it.ownerPid
+      doAssert ctx.isPrivileged(client) or client == it.owner
       return it
   return nil
 
@@ -464,11 +504,10 @@ proc redirectToFile(ctx: var LoaderContext; output: OutputHandle;
       stream: ps,
       istreamAtEnd: output.istreamAtEnd,
       outputId: ctx.getOutputId(),
-      bytesSent: osent
+      bytesSent: osent,
+      url: output.url
     )
     output.parent.outputs.add(fileOutput)
-    when defined(debug):
-      fileOutput.url = output.url
   return true
 
 proc getTempFile(ctx: var LoaderContext): string =
@@ -777,17 +816,16 @@ proc findItem(authMap: seq[AuthItem]; origin: Origin): AuthItem =
       return it
   return nil
 
-proc includeCredentials(config: LoaderClientConfig; request: Request; url: URL;
-    header: string): bool =
-  if header in request.headers:
-    return false
+proc includeCredentials(config: LoaderClientConfig; request: Request; url: URL):
+    bool =
   return request.credentialsMode == cmInclude or
     request.credentialsMode == cmSameOrigin and
       config.originURL == nil or
         url.origin.isSameOrigin(config.originURL.origin)
 
 proc findAuth(client: ClientHandle; request: Request; url: URL): AuthItem =
-  if client.config.includeCredentials(request, url, "Authorization"):
+  if "Authorization" notin request.headers and
+      client.config.includeCredentials(request, url):
     if client.authMap.len > 0:
       return client.authMap.findItem(url.authOrigin)
   return nil
@@ -996,13 +1034,14 @@ proc loadCGI(ctx: var LoaderContext; client: ClientHandle; handle: InputHandle;
       ostream.sclose()
     of rbtOutput:
       ostream.setBlocking(false)
-      let output = ctx.tee(outputIn, ostream, client.pid)
+      let output = ctx.tee(outputIn, ostream, client)
       output.suspended = false
       if not output.isEmpty:
         ctx.register(output)
     of rbtCache:
       if ostream != nil:
-        let handle = ctx.newInputHandle(ostream, client.pid, suspended = false)
+        let handle = ctx.newInputHandle(ostream, client,
+          newURL("cache:/dev/null").get, credentials = false, suspended = false)
         handle.stream = istream2
         ostream.setBlocking(false)
         ctx.loadStreamRegular(handle, cachedHandle)
@@ -1093,9 +1132,7 @@ proc loadDataSend(ctx: var LoaderContext; handle: InputHandle; s, ct: string) =
     else:
       ctx.oclose(output)
     return
-  let buffer = newLoaderBuffer(s.len)
-  buffer.len = s.len
-  copyMem(addr buffer.page[0], unsafeAddr s[0], s.len)
+  let buffer = newLoaderBuffer(s)
   case ctx.pushBuffer(output, buffer, ignoreSuspension = false)
   of pbrUnregister:
     if output.registered:
@@ -1212,26 +1249,19 @@ proc parseDownloadActions(ctx: LoaderContext; s: string): seq[DownloadAction] =
   result.sort(proc(a, b: DownloadAction): int = return cmp(a.n, b.n),
     Descending)
 
-proc loadAbout(ctx: var LoaderContext; handle: InputHandle; request: Request) =
-  let url = request.url
-  case url.pathname
-  of "blank":
-    ctx.loadDataSend(handle, "", "text/html")
-  of "chawan":
-    const body = staticRead"res/chawan.html"
-    ctx.loadDataSend(handle, body, "text/html")
-  of "downloads":
-    if request.httpMethod == hmPost:
-      # OK/STOP/PAUSE/RESUME clicked
-      if request.body.t != rbtString:
-        ctx.rejectHandle(handle, ceInvalidURL, "wat")
-        return
-      for it in ctx.parseDownloadActions(request.body.s):
-        let dl = ctx.downloadList[it.n]
-        if dl.output != nil:
-          ctx.unregWrite.add(dl.output)
-        ctx.downloadList.del(it.n)
-    var body = """
+proc loadDownloads(ctx: var LoaderContext; handle: InputHandle;
+    request: Request) =
+  if request.httpMethod == hmPost:
+    # OK clicked
+    if request.body.t != rbtString:
+      ctx.rejectHandle(handle, ceInvalidURL, "wat")
+      return
+    for it in ctx.parseDownloadActions(request.body.s):
+      let dl = ctx.downloadList[it.n]
+      if dl.output != nil:
+        ctx.unregWrite.add(dl.output)
+      ctx.downloadList.del(it.n)
+  var body = """
 <!DOCTYPE html>
 <title>Download List Panel</title>
 <body>
@@ -1241,34 +1271,65 @@ proc loadAbout(ctx: var LoaderContext; handle: InputHandle; request: Request) =
 <hr>
 <pre>
 """
-    let now = getTime()
-    var refresh = false
-    for i, it in ctx.downloadList.mpairs:
-      if it.output != nil:
-        it.sent = it.output.bytesSent
-        if it.output.stream == nil:
-          it.output = nil
-        refresh = true
-      body &= it.makeProgress(now)
-      body &= "<input type=submit name=stop" & $i
-      if it.output != nil:
-        body &= " value=STOP"
-      else:
-        body &= " value=OK"
-      body &= ">"
-      body &= "<hr>"
-    if refresh:
-      body &= "<meta http-equiv=refresh content=1>" # :P
-    body &= """
+  let now = getTime()
+  var refresh = false
+  for i, it in ctx.downloadList.mpairs:
+    if it.output != nil:
+      it.sent = it.output.bytesSent
+      if it.output.stream == nil:
+        it.output = nil
+      refresh = true
+    body &= it.makeProgress(now)
+    body &= "<input type=submit name=stop" & $i
+    if it.output != nil:
+      body &= " value=STOP"
+    else:
+      body &= " value=OK"
+    body &= ">"
+    body &= "<hr>"
+  if refresh:
+    body &= "<meta http-equiv=refresh content=1>" # :P
+  body &= """
 </pre>
 </body>
 """
+  ctx.loadDataSend(handle, body, "text/html")
+
+# Stream for notifying the pager of new cookies set in the loader.
+proc loadCookieStream(ctx: var LoaderContext; handle: InputHandle;
+    request: Request) =
+  if ctx.cookieStream != nil:
+    ctx.rejectHandle(handle, ceCookieStreamExists)
+    return
+  case ctx.sendResult(handle, 0)
+  of pbrDone: discard
+  of pbrUnregister:
+    ctx.close(handle)
+    return
+  case ctx.sendStatus(handle, 200, newHeaders(hgResponse))
+  of pbrDone: discard
+  of pbrUnregister:
+    ctx.close(handle)
+    return
+  ctx.cookieStream = handle.output
+
+proc loadAbout(ctx: var LoaderContext; handle: InputHandle; request: Request) =
+  let url = request.url
+  case url.pathname
+  of "blank":
+    ctx.loadDataSend(handle, "", "text/html")
+  of "chawan":
+    const body = staticRead"res/chawan.html"
     ctx.loadDataSend(handle, body, "text/html")
+  of "downloads":
+    ctx.loadDownloads(handle, request)
+  of "cookie-stream":
+    ctx.loadCookieStream(handle, request)
   of "license":
     const body = staticRead"res/license.md"
     ctx.loadDataSend(handle, body, "text/markdown")
   else:
-    ctx.rejectHandle(handle, ceInvalidURL, "invalid download URL")
+    ctx.rejectHandle(handle, ceInvalidURL, "invalid about URL")
 
 proc loadResource(ctx: var LoaderContext; client: ClientHandle;
     config: LoaderClientConfig; request: Request; handle: InputHandle) =
@@ -1319,12 +1380,13 @@ proc loadResource(ctx: var LoaderContext; client: ClientHandle;
   if tries >= MaxRewrites:
     ctx.rejectHandle(handle, ceTooManyRewrites)
 
-proc setupRequestDefaults(request: Request; config: LoaderClientConfig) =
+proc setupRequestDefaults(request: Request; config: LoaderClientConfig;
+    credentials: bool) =
   for k, v in config.defaultHeaders.allPairs:
     if k notin request.headers:
       request.headers[k] = v
   if config.cookieJar != nil and config.cookieJar.cookies.len > 0:
-    if config.includeCredentials(request, request.url, "Cookie"):
+    if "Cookie" notin request.headers and credentials:
       let cookie = config.cookieJar.serialize(request.url)
       if cookie != "":
         request.headers["Cookie"] = cookie
@@ -1348,14 +1410,12 @@ proc load(ctx: var LoaderContext; stream: SocketStream; request: Request;
     discard close(sv[1])
     let stream = newSocketStream(sv[0])
     stream.setBlocking(false)
-    let handle = ctx.newInputHandle(stream, client.pid)
-    when defined(debug):
-      handle.url = request.url
-      handle.output.url = request.url
+    let credentials = config.includeCredentials(request, request.url)
+    let handle = ctx.newInputHandle(stream, client, request.url, credentials)
     if not config.filter.match(request.url):
       ctx.rejectHandle(handle, ceDisallowedURL)
     else:
-      request.setupRequestDefaults(config)
+      request.setupRequestDefaults(config, credentials)
       ctx.loadResource(client, config, request, handle)
 
 proc load(ctx: var LoaderContext; stream: SocketStream; client: ClientHandle;
@@ -1555,11 +1615,13 @@ proc tee(ctx: var LoaderContext; stream: SocketStream; client: ClientHandle;
   r.sread(sourceId)
   r.sread(targetPid)
   let outputIn = ctx.findOutput(sourceId, client)
+  let target = ctx.clientMap.getOrDefault(targetPid)
   var sv {.noinit.}: array[2, cint]
-  if outputIn != nil and socketpair(AF_UNIX, SOCK_STREAM, IPPROTO_IP, sv) == 0:
+  if target != nil and outputIn != nil and
+      socketpair(AF_UNIX, SOCK_STREAM, IPPROTO_IP, sv) == 0:
     let ostream = newSocketStream(sv[0])
     ostream.setBlocking(false)
-    let output = ctx.tee(outputIn, ostream, targetPid)
+    let output = ctx.tee(outputIn, ostream, target)
     stream.withPacketWriter w:
       w.swrite(output.outputId)
       w.sendFd(sv[1])
diff --git a/src/server/loaderiface.nim b/src/server/loaderiface.nim
index 4348acce..0b00a22a 100644
--- a/src/server/loaderiface.nim
+++ b/src/server/loaderiface.nim
@@ -24,7 +24,7 @@ type
   FileLoader* = ref object
     clientPid*: int
     map: seq[MapData]
-    mapFds: int # number of fds in map
+    mapFds*: int # number of fds in map
     unregistered*: seq[int]
     registerFun*: proc(fd: int)
     unregisterFun*: proc(fd: int)
@@ -83,6 +83,7 @@ type
     proxy*: URL
     referrerPolicy*: ReferrerPolicy
     insecureSslNoVerify*: bool
+    cookieMode*: CookieMode
 
 proc getRedirect*(response: Response; request: Request): Request =
   if response.status in 301u16..303u16 or response.status in 307u16..308u16:
diff --git a/test/net/cookie.css.http b/test/net/cookie.css.http
new file mode 100644
index 00000000..72b9a993
--- /dev/null
+++ b/test/net/cookie.css.http
@@ -0,0 +1,4 @@
+Content-Type: text/css
+Set-Cookie: test9=css
+
+#y { display: none }
diff --git a/test/net/cookie.http b/test/net/cookie.http
index bd9fabb4..d0abe683 100644
--- a/test/net/cookie.http
+++ b/test/net/cookie.http
@@ -9,7 +9,9 @@ Set-Cookie: test6=hi; Max-Age=9223372036854775807
 Set-Cookie: test7=hi; Expires=Mon 0 Jan 1999 20:30:00 GMT
 Set-Cookie: test8=hi; Expires=Mon, 31 Feb 1999 20:30:00 GMT
 
+<link rel=stylesheet href=cookie.css.http>
 <div id=x>Fail</div>
+<div id=y>CSS fail</div>
 <script src=asserts.js></script>
 <script>
 const x = new XMLHttpRequest();
@@ -17,6 +19,6 @@ x.open("GET", "headers", false);
 x.overrideMimeType("text/plain");
 x.send();
 const cookie = x.responseText.split('\n').find(x => x.startsWith("cookie:"));
-assertEquals(cookie.split(': ').pop(), "test=asdfasdf; SID=31d4d96e407aad42; test3=x; test4=y; test6=hi; test7=hi; test8=hi");
+assertEquals(cookie.split(': ').pop(), "test=asdfasdf; SID=31d4d96e407aad42; test3=x; test4=y; test6=hi; test7=hi; test8=hi; test9=css");
 document.getElementById("x").textContent = "Success";
 </script>
diff --git a/test/net/run.sh b/test/net/run.sh
index a257cefc..539de55d 100755
--- a/test/net/run.sh
+++ b/test/net/run.sh
@@ -6,7 +6,10 @@ fi
 
 failed=0
 for h in *.html *.http
-do	printf '%s\r' "$h"
+do	case $h in
+	cookie.css.http) continue;;
+	esac
+	printf '%s\r' "$h"
 	if ! "$CHA" -C config.toml "http://localhost:$1/$h" | diff all.expected -
 	then	failed=$(($failed+1))
 		printf 'FAIL: %s\n' "$h"