headscale icon indicating copy to clipboard operation
headscale copied to clipboard

Handle CORS headers and OPTIONS method for HTTP API

Open routerino opened this issue 2 years ago • 15 comments

Bug description

When trying to use a browser to generate API requests (like, hypothetically, if you're building a web frontend for headscale), the browser expects to to use CORS to determine if it can talk to the external server. The browser does this by the following:

  • Sending a pre-flight OPTIONS request, expecting back a 204 response with the CORS headers attached
  • Once the response is accepted, sending the real API request with all the data

For this to work, we need two things:

  • The server (headscale) to generate CORS headers (and have it be configurable to set the domains appropriately)
  • The server to accept OPTIONS requests without authorization.

To Reproduce Generate a fetch request from a browser in a separate domain. Such as:

let apiKey = '<my api key>';
let url = 'https://<my headscale domain>/api/v1/machine';

window.fetch(url, {
    method: 'GET',
    headers: {
        Authorization: `Bearer ${apiKey}`
    }}).then((resp) => resp.json()).then(function (data) {console.log(data);}).catch(function (error) {console.log(error);});});

If no CORS headers are specified, you get this nice error in the browser console: image

If you have the right headers (if you, for example, inject them with a reverse proxy) but the OPTIONS request is blocked by authorization, you get this nice error instead: image

Because the OPTIONS request is returning a 401 unauthorized when it shouldn't.

Both are not ideal. You can fix both with a reverse proxy, but you certainly shouldn't have to. The web server (gin?) should return OPTIONS with a 204 and be setting the CORS headers on all requests (and the CORS headers should be configurable).

Context info

These problems were fixed externally by routing through a Caddy reverse proxy using these matching settings:

@hs-options {
	host hs.<my-domain>
	method OPTIONS
}
@hs-other {
	host hs.<my-domain>
}
handle @hs-options {
	header {
		Access-Control-Allow-Origin https://<my-frontend-subdomain>
		Access-Control-Allow-Headers *
	}
	respond 204
}
handle @hs-other {
	reverse_proxy http://headscale:8080 {
		header_down Access-Control-Allow-Origin https://<my-frontend-subdomain>
		header_down Access-Control-Allow-Headers *
	}
}

routerino avatar Jun 09 '22 13:06 routerino

If you use caddy-docker-proxy, here's the same (mostly) config, done in labels:

    labels:
      caddy: "headscale.${BASE_DOMAIN}"
      [email protected]: "headscale.${BASE_DOMAIN}"
      [email protected]: "headscale.${BASE_DOMAIN}"
      [email protected]: OPTIONS

      caddy.0_import: tlsdns

      caddy.1_handle: "@hs-options"
      caddy.1_handle.header.Access-Control-Allow-Origin: "https://ui.headscale.${BASE_DOMAIN}"
      caddy.1_handle.header.Access-Control-Allow-Headers: "*"
      caddy.1_handle.header.Access-Control-Allow-Methods: '"POST, GET, OPTIONS, DELETE"'
      caddy.1_handle.respond: "204"

      caddy.8_handle: /metrics
      caddy.8_handle.import: auth
      caddy.8_handle.reverse_proxy: "{{upstreams 9090}}"

      caddy.9_handle: "@hs-other"
      caddy.9_handle.reverse_proxy: "{{upstreams 80}}"
      caddy.9_handle.reverse_proxy.header_down_1: "Access-Control-Allow-Origin https://ui.headscale.${BASE_DOMAIN}"
      caddy.9_handle.reverse_proxy.header_down_2: "Access-Control-Allow-Headers *"
      caddy.9_handle.reverse_proxy.header_down_3: 'Access-Control-Allow-Methods "POST, GET, OPTIONS, DELETE"'

Mikle-Bond avatar Jan 21 '23 14:01 Mikle-Bond

Someone know how to configure it for Traefik? I tryed to add next labels:

traefik.http.routers.headscale-public-https.middlewares: headscale-cors
traefik.http.middlewares.headscale-cors.headers.customResponseHeaders.Access-Control-Allow-Origin: https://web.headscale.yourdomain.example
traefik.http.middlewares.headscale-cors.headers.customResponseHeaders.Access-Control-Allow-Methods: GET, POST, PUT, DELETE
traefik.http.middlewares.headscale-cors.headers.customResponseHeaders.Access-Control-Allow-Headers: Content-Type

Looks like the first part of the problem is solved, but I have a problem with 204 status code in the answer..

I think maybe this plugin should help but I can't configure it properly, it shows me error "status code is smallest than minimum value: 100" (Issue with details opened here: https://github.com/Medzoner/traefik-plugin-cors-preflight/issues/8)

deimjons avatar Aug 30 '23 19:08 deimjons

@deimjons Maybe try this plugin instead? https://plugins.traefik.io/plugins/628c9f0f108ecc83915d7771/replace-status-code

Mikle-Bond avatar Aug 31 '23 10:08 Mikle-Bond

@Mikle-Bond Thank you for your attention. I tried this plugin but it didn't help me. I don't know: I doing something wrong or the plugin just not working.

I have added additional routes in labels:

      traefik.http.routers.headscale-options.rule: Host(`headscale.yourdomain.example/api/v1/apikey`) && Method(`OPTIONS`)
      traefik.http.routers.headscale-options.entrypoints: websecure
      traefik.http.routers.headscale-options.tls: true
      traefik.http.routers.headscale-options.tls.certresolver: prod
      traefik.http.routers.headscale-options.middlewares: replace-response-code@file  

also, I added a plugin and middleware (like they show in the documentation example) in the configuration file of traefik: traefik.yaml

experimental:
  plugins:
    traefik-replace-response-code:
      moduleName: "github.com/pierre-verhaeghe/traefik-replace-response-code"
      version: "v0.2.0"

http:
  middlewares:
    replace-response-code:
      plugin:
        traefik-replace-response-code:
          inputCode: 401
          outputCode: 200
          removeBody: "true"

As a result, I have the same error:

Access to fetch at 'https://headscale.yourdomain.example/api/v1/apikey' from origin 'https://admin.headscale.yourdomain.example' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: It does not have HTTP ok status.

deimjons avatar Sep 01 '23 18:09 deimjons

Someone know how to configure it for Traefik? I tryed to add next labels:

traefik.http.routers.headscale-public-https.middlewares: headscale-cors
traefik.http.middlewares.headscale-cors.headers.customResponseHeaders.Access-Control-Allow-Origin: https://web.headscale.yourdomain.example
traefik.http.middlewares.headscale-cors.headers.customResponseHeaders.Access-Control-Allow-Methods: GET, POST, PUT, DELETE
traefik.http.middlewares.headscale-cors.headers.customResponseHeaders.Access-Control-Allow-Headers: Content-Type

Looks like the first part of the problem is solved, but I have a problem with 204 status code in the answer..

I think maybe this plugin should help but I can't configure it properly, it shows me error "status code is smallest than minimum value: 100" (Issue with details opened here: Medzoner/traefik-plugin-cors-preflight#8)

did you manage? I also have to do this

edit: https://doc.traefik.io/traefik/v2.4/middlewares/headers/

mich2k avatar Sep 20 '23 10:09 mich2k

no, I use it via prefix /admin.. ((

deimjons avatar Oct 01 '23 04:10 deimjons

How to add this to Nginx Proxy Manager ?

masterwishx avatar Jan 07 '24 09:01 masterwishx

How to add this to Nginx Proxy Manager ?

Hi, did you ever figure this out? I am also unable to access api via NPM.

sapstar avatar Feb 11 '24 16:02 sapstar

How to add this to Nginx Proxy Manager ?

Hi, did you ever figure this out? I am also unable to access api via NPM.

Yes, all working fine, if you using cloudflare disable the proxy (orange cloud)

masterwishx avatar Feb 11 '24 16:02 masterwishx

Thank you very much. That sorted it.

sapstar avatar Feb 12 '24 07:02 sapstar

I hope to support CORS, and I would like to use healscale directly instead of using Nginx and other programs for proxy, which is very inconvenient

fcwys avatar Feb 29 '24 06:02 fcwys

If you use caddy-docker-proxy, here's the same (mostly) config, done in labels:

    labels:
      caddy: "headscale.${BASE_DOMAIN}"
      [email protected]: "headscale.${BASE_DOMAIN}"
      [email protected]: "headscale.${BASE_DOMAIN}"
      [email protected]: OPTIONS

      caddy.0_import: tlsdns

      caddy.1_handle: "@hs-options"
      caddy.1_handle.header.Access-Control-Allow-Origin: "https://ui.headscale.${BASE_DOMAIN}"
      caddy.1_handle.header.Access-Control-Allow-Headers: "*"
      caddy.1_handle.header.Access-Control-Allow-Methods: '"POST, GET, OPTIONS, DELETE"'
      caddy.1_handle.respond: "204"

      caddy.8_handle: /metrics
      caddy.8_handle.import: auth
      caddy.8_handle.reverse_proxy: "{{upstreams 9090}}"

      caddy.9_handle: "@hs-other"
      caddy.9_handle.reverse_proxy: "{{upstreams 80}}"
      caddy.9_handle.reverse_proxy.header_down_1: "Access-Control-Allow-Origin https://ui.headscale.${BASE_DOMAIN}"
      caddy.9_handle.reverse_proxy.header_down_2: "Access-Control-Allow-Headers *"
      caddy.9_handle.reverse_proxy.header_down_3: 'Access-Control-Allow-Methods "POST, GET, OPTIONS, DELETE"'

Has anyone else made this work I can't figure it out.

B08Z avatar Feb 29 '24 14:02 B08Z

I don't use Caddy, and I don't actually have any plans to use it. I just want to run Headscale directly.

fcwys avatar Feb 29 '24 16:02 fcwys

Cross-Origin Request Warning: The Same Origin Policy will disallow reading the remote resource at https://headscale.domain.com/api/v1/node soon. (Reason: When the Access-Control-Allow-Headers is *, the Authorization header is not covered. To include the Authorization header, it must be explicitly listed in CORS header Access-Control-Allow-Headers).

I am getting this error with the above implementation using Headscale-admin

B08Z avatar Mar 04 '24 10:03 B08Z