Repository: firebase/firebase-tools (ts)

Issue:
Firebase emulator stripping `Authentication` header when rewriting a function
<!-- DO NOT DELETE
validate_template=true
template_path=.github/ISSUE_TEMPLATE/bug_report.md
-->

<!--
Thank you for contributing to the Firebase community!

Think you found a bug?
=======================
Yeah, we're definitely not perfect! Please use this template and include a minimal repro when opening the issue. If you know how to solve the issue, please create a Pull Request, and we'd be happy to review it!

Have a feature request?
========================
Great, we love hearing how we can improve our products! However, do not use this template to submit a feature request. Please submit your feature requests to: https://firebase.google.com/support/contact/bugs-features/

Have a usage question?
=======================
We get lots of those and we love helping you, but GitHub is not the best place for them and they will be closed. Please take a look at the guide first: https://firebase.google.com/docs/cli/

If the official documentation doesn't help, try asking through our official support channel: https://firebase.google.com/support/

Additional locations to check for solutions or assistance from the community:
- Stack Overflow: https://stackoverflow.com/
- Firebase Slack Community: https://firebase.community/

*Please avoid duplicate posting across multiple channels!*
-->

### [REQUIRED] Environment info

<!-- What version of the Firebase CLI (`firebase-tools`) are you using? Note that your issue may already be fixed in the latest versions. The latest version can be found at https://github.com/firebase/firebase-tools/releases -->

Affects:
8.17.0
8.18.0

NOT present in:
8.16.2

<!-- Output of `firebase --version` -->

**firebase-tools:** 8.18.0

<!-- e.g. macOS, Windows, Ubuntu -->

**Platform:** macOS

### [REQUIRED] Test case

```
#Somefile.js
const app = express();
app.get('/foo', (req, res) => console.log(req.get('Authorization'));
export testAuth = functions.https.onRequest(app);

#firestore.json
"hosting": [
    {
      "target": "testTarget",
      "public": "public",
      "rewrites": [
        {
          "source": "/testAuth/**",
          "function": "testAuth"
        },
...
```
<!-- Provide a minimal, complete, and verifiable example (http://stackoverflow.com/help/mcve) -->

### [REQUIRED] Steps to reproduce

1. Start the firebase emulator:

firebase -P testTarget emulators:start --inspect-functions

2. send a curl command to the rewritten function, placing a bearer token on the command-line:

curl -H "Authorization: Bearer eyJhbGc..." localhost:5000/testAuth


<!-- Provide the steps needed to reproduce the issue with the above test case. -->

### [REQUIRED] Expected behavior

The function should list the passed-in auth header in the output.


<!-- What is the expected behavior? -->

### [REQUIRED] Actual behavior


Instead, the auth header has been stripped (it's undefined):
> `undefined`

If I rerun directly calling the function (without having `hosting` do a rewrite, it successfully prints the auth token)

curl -H "Authorization: Bearer eyJhbGc..." localhost:5001/us-central1/someProjectId/testAuth

> `Authorization: Bearer eyJhbGc`

<!-- Run the command with --debug flag, and include the logs below. -->





Pull request:
Fix header forwarding in Hosting emulator
<!--

Thank you for contributing to the Firebase community! Please fill out the form below.

Run the linter and test suite
==============================
Run `npm test` to make sure your changes compile properly and the tests all pass on your local machine. We've hooked up this repo with continuous integration to double check those things for you.

-->

### Description

When proxying to Run|Functions, we need to pass along any headers that were in the original request. The move from `request` to `node-fetch` dropped this because `request` allowed "piping the headers", so to speak, when it was piped through. This explicitly copies the headers over and sends them along.

Fixes #2910 

### Scenarios Tested

I have a Function that echos the headers sent to it. When I run the emulator without this fix, I don't see my custom headers in the request. With this patch, things are working as expected.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e69de29bb..be53e5d4c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -0,0 +1,1 @@
+- Fixes issue where emulated functions were not receiving all expected headers.
diff --git a/src/apiv2.ts b/src/apiv2.ts
index 2029da96e..e07b40fff 100644
--- a/src/apiv2.ts
+++ b/src/apiv2.ts
@@ -1,4 +1,4 @@
-import fetch, { Response, RequestInit } from "node-fetch";
+import fetch, { HeadersInit, Response, RequestInit, Headers } from "node-fetch";
 import { AbortSignal } from "abort-controller";
 import { Readable } from "stream";
 import { URLSearchParams } from "url";
@@ -22,7 +22,7 @@ interface RequestOptions<T> extends VerbOptions<T> {
 
 interface VerbOptions<T> {
   method?: HttpMethod;
-  headers?: { [key: string]: string };
+  headers?: HeadersInit;
   queryParams?: URLSearchParams | { [key: string]: string | number };
 }
 
@@ -37,6 +37,10 @@ interface ClientHandlingOptions {
 
 export type ClientRequestOptions<T> = RequestOptions<T> & ClientVerbOptions<T>;
 
+interface InternalClientRequestOptions<T> extends ClientRequestOptions<T> {
+  headers?: Headers;
+}
+
 export type ClientVerbOptions<T> = VerbOptions<T> & ClientHandlingOptions;
 
 export type ClientResponse<T> = {
@@ -170,13 +174,17 @@ export class Client {
       );
     }
 
-    reqOptions = this.addRequestHeaders(reqOptions);
+    let internalReqOptions: InternalClientRequestOptions<ReqT> = Object.assign(reqOptions, {
+      headers: new Headers(reqOptions.headers),
+    });
+
+    internalReqOptions = this.addRequestHeaders(internalReqOptions);
 
     if (this.opts.auth) {
-      reqOptions = await this.addAuthHeader(reqOptions);
+      internalReqOptions = await this.addAuthHeader(internalReqOptions);
     }
     try {
-      return await this.doRequest<ReqT, ResT>(reqOptions);
+      return await this.doRequest<ReqT, ResT>(internalReqOptions);
     } catch (err) {
       if (err instanceof FirebaseError) {
         throw err;
@@ -185,27 +193,29 @@ export class Client {
     }
   }
 
-  private addRequestHeaders<T>(reqOptions: ClientRequestOptions<T>): ClientRequestOptions<T> {
+  private addRequestHeaders<T>(
+    reqOptions: InternalClientRequestOptions<T>
+  ): InternalClientRequestOptions<T> {
     if (!reqOptions.headers) {
-      reqOptions.headers = {};
+      reqOptions.headers = new Headers();
     }
-    reqOptions.headers["Connection"] = "keep-alive";
-    reqOptions.headers["User-Agent"] = `FirebaseCLI/${CLI_VERSION}`;
-    reqOptions.headers["X-Client-Version"] = `FirebaseCLI/${CLI_VERSION}`;
+    reqOptions.headers.set("Connection", "keep-alive");
+    reqOptions.headers.set("User-Agent", `FirebaseCLI/${CLI_VERSION}`);
+    reqOptions.headers.set("X-Client-Version", `FirebaseCLI/${CLI_VERSION}`);
     if (reqOptions.responseType === "json") {
-      reqOptions.headers["Content-Type"] = "application/json";
+      reqOptions.headers.set("Content-Type", "application/json");
     }
     return reqOptions;
   }
 
   private async addAuthHeader<T>(
-    reqOptions: ClientRequestOptions<T>
-  ): Promise<ClientRequestOptions<T>> {
+    reqOptions: InternalClientRequestOptions<T>
+  ): Promise<InternalClientRequestOptions<T>> {
     if (!reqOptions.headers) {
-      reqOptions.headers = {};
+      reqOptions.headers = new Headers();
     }
     const token = await this.getAccessToken();
-    reqOptions.headers["Authorization"] = `Bearer ${token}`;
+    reqOptions.headers.set("Authorization", `Bearer ${token}`);
     return reqOptions;
   }
 
@@ -218,13 +228,13 @@ export class Client {
     return data.access_token;
   }
 
-  private requestURL(options: ClientRequestOptions<unknown>): string {
+  private requestURL(options: InternalClientRequestOptions<unknown>): string {
     const versionPath = this.opts.apiVersion ? `/${this.opts.apiVersion}` : "";
     return `${this.opts.urlPrefix}${versionPath}${options.path}`;
   }
 
   private async doRequest<ReqT, ResT>(
-    options: ClientRequestOptions<ReqT>
+    options: InternalClientRequestOptions<ReqT>
   ): Promise<ClientResponse<ResT>> {
     if (!options.path.startsWith("/")) {
       options.path = "/" + options.path;
@@ -299,7 +309,7 @@ export class Client {
     };
   }
 
-  private logRequest(options: ClientRequestOptions<unknown>): void {
+  private logRequest(options: InternalClientRequestOptions<unknown>): void {
     let queryParamsLog = "[none]";
     if (options.queryParams) {
       queryParamsLog = "[omitted]";
@@ -321,7 +331,11 @@ export class Client {
     }
   }
 
-  private logResponse(res: Response, body: unknown, options: ClientRequestOptions<unknown>): void {
+  private logResponse(
+    res: Response,
+    body: unknown,
+    options: InternalClientRequestOptions<unknown>
+  ): void {
     const logURL = this.requestURL(options);
     logger.debug(`<<< [apiv2][status] ${options.method} ${logURL} ${res.status}`);
     let logBody = "[omitted]";
diff --git a/src/hosting/proxy.ts b/src/hosting/proxy.ts
index feb36df55..52ec79a75 100644
--- a/src/hosting/proxy.ts
+++ b/src/hosting/proxy.ts
@@ -8,7 +8,7 @@ import AbortController from "abort-controller";
 import { Client, HttpMethod } from "../apiv2";
 import { FirebaseError } from "../error";
 import * as logger from "../logger";
-import { FetchError } from "node-fetch";
+import { FetchError, Headers } from "node-fetch";
 
 const REQUIRED_VARY_VALUES = ["Accept-Encoding", "Authorization", "Cookie"];
 
@@ -62,20 +62,35 @@ export function proxyRequestHandler(url: string, rewriteIdentifier: string): Req
       req.pipe(passThrough);
     }
 
+    const headers = new Headers({
+      "X-Forwarded-Host": req.headers.host || "",
+      "X-Original-Url": req.url || "",
+      Pragma: "no-cache",
+      "Cache-Control": "no-cache, no-store",
+      // forward the parsed __session cookie if any
+      Cookie: sessionCookie || "",
+    });
+    for (const key of Object.keys(req.headers)) {
+      const value = req.headers[key];
+      if (value == undefined) {
+        headers.delete(key);
+      } else if (Array.isArray(value)) {
+        headers.delete(key);
+        for (const v of value) {
+          headers.append(key, v);
+        }
+      } else {
+        headers.set(key, value);
+      }
+    }
+
     let proxyRes;
     try {
       proxyRes = await c.request<unknown, NodeJS.ReadableStream>({
         method: (req.method || "GET") as HttpMethod,
         path: u.pathname,
         queryParams: u.searchParams,
-        headers: {
-          "X-Forwarded-Host": req.headers.host || "",
-          "X-Original-Url": req.url || "",
-          Pragma: "no-cache",
-          "Cache-Control": "no-cache, no-store",
-          // forward the parsed __session cookie if any
-          Cookie: sessionCookie || "",
-        },
+        headers,
         resolveOnHTTPError: true,
         responseType: "stream",
         redirect: "manual",


Test patch:
diff --git a/src/test/hosting/cloudRunProxy.spec.ts b/src/test/hosting/cloudRunProxy.spec.ts
index 2e61f54bd..bd99dcc3a 100644
--- a/src/test/hosting/cloudRunProxy.spec.ts
+++ b/src/test/hosting/cloudRunProxy.spec.ts
@@ -88,6 +88,27 @@ describe("cloudRunProxy", () => {
       });
   });
 
+  it("should pass on provided headers to the origin", async () => {
+    nock(cloudRunApiOrigin)
+      .get("/v1/projects/project-foo/locations/us-central1/services/helloworld")
+      .reply(200, { status: { url: cloudRunServiceOrigin } });
+    nock(cloudRunServiceOrigin, { reqheaders: { "x-custom-header": "cooooookie-crisp" } })
+      .get("/")
+      .reply(200, "live version");
+
+    const mwGenerator = cloudRunProxy(fakeOptions);
+    const mw = await mwGenerator(fakeRewrite);
+    const spyMw = sinon.spy(mw);
+
+    return supertest(spyMw)
+      .get("/")
+      .set("x-custom-header", "cooooookie-crisp")
+      .expect(200, "live version")
+      .then(() => {
+        expect(spyMw.calledOnce).to.be.true;
+      });
+  });
+
   it("should resolve to a live version in another region", async () => {
     const cloudRunServiceOriginAsia = "https://helloworld-hash-as.a.run.app";
     nock(cloudRunApiOrigin)