graphql-engine icon indicating copy to clipboard operation
graphql-engine copied to clipboard

Allow whitelisted session variables to be passed through HTTP headers

Open lanthaler opened this issue 5 years ago • 44 comments

Currently all X-Hasura-* HTTP headers are filtered and not available to to be used in column presets or permission checks. This means that switching between organizations in the example use case in the docs requires to either use webhook based auth or to get a new the JWT each time the user switches to a different organization.

I discussed this briefly with @coco98 on Discord and he mentioned this is for security reasons. I do understand the reasoning but don't think it should apply to all headers. Enforcing that the user only acts on behalf of allowed organization is still possible. Similarly, there are probably plenty of use cases where it the information passed via a HTTP header has nothing to do with auth (I'm thinking of things such as passing the client version, A/B experiment logging etc.).

Would it be possible to whitelist certain headers to be used as session variables?

lanthaler avatar Feb 13 '19 20:02 lanthaler

Interesting use-case.

I'm trying to break this down with an example. Please correct me if I misunderstand at any point.

Let's say for the multi-org example - you want to issue a single JWT which contains the list of orgs the user belongs to? And then from the client you want to send a particular header to indicate the current org? But, where would you validate that the client is sending the correct org-id, and that the user is actually allowed access to that org-id? The JWT contains the list of allowed orgs for the user, but where can this validation happen? So I'm not sure what the possible solution can be here.

For the non-auth use-cases: let's say you want to send client version as a header, and then use this value in the permission to restrict some tables/columns to the user. And if this header is just directly passed from the client (i.e. no auth server is "validating" it), wouldn't it be possible for the client to just spoof it?

A detailed example of your use-case would be helpful for us to understand what the exact limitation is, and what are the possible solutions.

ecthiender avatar Feb 14 '19 06:02 ecthiender

Sorry for the late reply. I missed the notification about your comment somehow. Answers inline below

Let's say for the multi-org example - you want to issue a single JWT which contains the list of orgs the user belongs to?

Maybe but that's not supported anyway at the moment (#1333). Currently I'd just look into a particular table in the DB to check whether a user does indeed belong to a specific org.

And then from the client you want to send a particular header to indicate the current org?

Correct.

But, where would you validate that the client is sending the correct org-id, and that the user is actually allowed access to that org-id? The JWT contains the list of allowed orgs for the user, but where can this validation happen? So I'm not sure what the possible solution can be here.

As long as there's a user-organization table I could check it by setting up a check for the org and the corresponding user ID which would come from the JWT as usual.

For the non-auth use-cases: let's say you want to send client version as a header, and then use this value in the permission to restrict some tables/columns to the user.

If I would use it to "restrict some tables/columns to the user" it would actually not be a non-auth use case.

And if this header is just directly passed from the client (i.e. no auth server is "validating" it), wouldn't it be possible for the client to just spoof it?

Sure, but since it's a non-auth case I wouldn't particularly care. I would just log the value. Think of an A/B experiment. The client would simply send the experiment ID A or B and I would silently log it in all relevant tables without having to touch all queries. A similar use case would be passing some marketing campaign, or affiliate ID along.

Of course, everything that can be send as a header, could also directly be included in the query. I think it would still be beneficial though to not to have to pollute all query interfaces by such cross-cutting concerns such as experiment logging or organization IDs.

I hope this clarifies it. If not, please let me know and I'll give it another try.

I haven't written any Haskell before so it's quite difficult for me to understand the code base but perhaps I'd be able to get this implemented with a little help from your side. I'd be happy to contribute a PR

lanthaler avatar Feb 18 '19 18:02 lanthaler

Thoughts?

lanthaler avatar Feb 28 '19 08:02 lanthaler

Sorry for the late reply! Somehow missed this.

I can see how this feature could be useful. Where would you specify what headers are allowed? Couple of options:

  1. In permissions. I'm not sure if this a great idea. Imagine you allow x-hasura-h for select permission for role 'r' on table t1 but not on table t2 and if you request t1 and t2 (say through relationship) the engine wouldn't know what to do.
  2. Part of authorization. In case of JWT, the claims can have a list of allowed_client_headers? Similarly the webhook can also return something similar? I like this better. However you lose the ability to configure this per table/permission.

Would this work?

0x777 avatar Mar 05 '19 13:03 0x777

I'm not sure I follow so let me try to explain how I see it. Currently Hasura filters all custom headers so that they aren't available to be used as default values or in (permission) checks. I'd like developers to pass data for cross-cutting concerns (selection of organization, logging, etc.) through headers so that not all queries have to be polluted. JWT would work, but I'm not sure what it would buy us. On the other hand, it unnecessarily increases the size of the JWT.

Ideally, I'd be able to specify a name as server flag or environment variable and would then be able to send arbitrary headers in the form of <name>-* that I can use for default values and checks (in case you wonder why I dropped the x- prefix).

In case you don't want to introduce such a blanket approach, I'd probably suggest to pass a list of allowed headers as server flag or env. variable that can then be used as usual in the form of x-hasura-<name>. The downside of this approach is obviously that you have a single namespace and apps my start to use something that you then later need to for Hasura itself.

lanthaler avatar Mar 06 '19 06:03 lanthaler

JWT would work, but I'm not sure what it would buy us. On the other hand, it unnecessarily increases the size of the JWT.

The only question here is to decide on the granularity with which this (allowing client side headers) can be configured. Few options:

  1. Global like you suggested, take a regex/list of headers
  2. Per user, by making it part of the authorization, i.e, jwt or webhook
  3. Per permission (this is the bit I was talking about under (1) in my previous reply)

0x777 avatar Mar 07 '19 10:03 0x777

The global one seems by far the easiest to implement and understand. Can you think of any disadvantage it might have?

The cons I see for 2) are increased overhead (JWT size increases) and complexity (the authentication service needs to know about additional, potentially completely auth-unrelated headers). I'm still not sure I fully understand what you have in mind for 3) but you already pointed out a problem in your previous comment

lanthaler avatar Mar 08 '19 07:03 lanthaler

Friendly ping

lanthaler avatar Apr 13 '19 12:04 lanthaler

Hi, sorry about the delay in getting back to you.

Global setting

Having this setting globally is definitely easy to implement. However I'm a little apprehensive about this feature (global setting) as even a slight misuse (or incorrect use) could mean that the permission rules may not be restricting access as they are supposed to. However we can add it as an advanced feature.

Per user setting

Increased JWT size

The JWT size will increase. However, it also means that the you have more control, only select users can now be allowed to set headers from the client.

.. additional, potentially completely auth-unrelated headers

Once you start using these variables in permission rules don't they become part of your app's authorization logic?

When we add this, we'll add it both globally and per user as part of JWT/webhook.

0x777 avatar Apr 23 '19 05:04 0x777

@coco98 is there any update with this? I have a similar use case:

  • User pushes a set of values to a session table
  • User is also subscribed to a view that uses the session table, and some user-table data, to filter the results made available in the subscription

Example

  1. user_x
    1. signed in having a session record_id matching its JWT;
    2. with roles W, H, and B (listed in the JWT);
    3. updates session record (via mutation) setting value_s=12 and value_e=14
  2. subscription: {show_view:{magic_data}} would instantiate a custom SQL function
    1. which would use the JWT token in the header;
    2. pull the value_s and value_e values from the session table, using the JWT as the PK;
    3. pull the x-hasura-allowed-roles from JWT;
    4. pull together the magic_data response from magic table using the *_s values and roles as filters;
    5. when any of *_s values, roles, or magic table changes, the subscription is updated.

This allows me to have a purely one-way app that supports clean A/B, multi-tenancy with the same user in the same instance of application session, and a fully-abstracted multi-purpose app framework.

The above example isn't real, obviously, but I can deal with the security implications if I can pull certain headers from the requests.

lancedouglas1 avatar Oct 01 '19 04:10 lancedouglas1

This would be very useful, its less of a case of security and more of context, in multi-tenant case say a user has 3 different contexts (eg companies) with associated data for those, it requires every query to pass in a company_id to every query as a variable, but if the dataset would get filtered based off the header x-hasura-context-id less chance of data cross contaminating. Also it then could be used on an insert as default value. The key being there is many scenarios where a shortcut like this can save a lot of extra code as well as enforcing that data consistency at a server level instead of client... similar to how we COULD send in updated_at or updated_by every time, but its nice not to have to.

chrishawn avatar Apr 07 '20 23:04 chrishawn

I think I have a use case for this. I have a record that normally USER A is not permitted to access. However, if given a special token from the owner of the record (USER B), USER A can pass this token in the header (e.g. x-hasura-uuid). USER A can now view this record. This is already possible using the anonymous role but it doesn't work for any other roles. This would be useful for providing share using a private link type functionality like google docs, etc.

aroraenterprise avatar Apr 10 '20 06:04 aroraenterprise

I have encountered this with the same use case as @aroraenterprise - passing a header that allows access to a specific record regardless of role.

martinpengellyphillips avatar Sep 01 '20 06:09 martinpengellyphillips

Same here, this would be highly beneficial.

I was disappointed to find that adding extra context from the client was not allowed, as that would be very useful when doing "exists" checks using the user ID (from the JWT) and a custom header session variable. Now I'm bound to switch to webhook mode, instead of using the built-in permissions system.

malkhuzayyim avatar Oct 12 '20 22:10 malkhuzayyim

I think it's critical feature to solve issues similar to https://github.com/hasura/graphql-engine/issues/3436 currently I have role anonymous which has access to all records in table. ultimately it should be solved conditionally based on id eq x-hasura-row-id

morriq avatar Nov 03 '20 05:11 morriq

I would also find this very useful for generating 'magic' links that give anyone possessing the link access to some specific information. Hasura's recommended services like Auth0 really don't support generating anonymous JWTs.

rossng avatar Dec 02 '20 13:12 rossng

This is precisely how I'd like to use it as well

ajbouh avatar Dec 02 '20 15:12 ajbouh

Got around this by hijacking the x-hasura-user-id field to contain the single row id and doing a custom check based on that. Of course the generated jwt will be invalid in all other scenarios but generating it with just the single explicit role works fine for my purposes.

For example, defined a role like specificRow and add a custom check that row.id _eq x-hasura-user-id

Screen Shot 2020-12-27 at 6 10 18 PM

Then generate a custom JWT with claims where specificRowId is the row you want to allow access to

'https://hasura.io/jwt/claims': {
  'x-hasura-allowed-roles': ['specificRow'],
  'x-hasura-default-role': 'specificRow',

  // hijack hasura user id header and send specific row id instead
  'x-hasura-user-id': specificRowId,
}

Now you can query the entire table and it will only return that single row

query MyQuery {
  row {
    id
  }
}

magus avatar Dec 28 '20 02:12 magus

This is a must-have feature for me. There are millions of new possibilities, if we can set session variables through http headers.

rolivegab avatar Dec 31 '20 20:12 rolivegab

Hey there,

Our use case for this feature is passing a X-Hasura-Team-Id with each request as we are building a multi-tenant application that supports switching organizations on the fly (similar to Slack). Using an HTTP header allows us to force clients with the user role to specify what team they want to view data for. This makes it easy to maintain separate caches inside our app, which keeps data from different teams completely isolated.

I think the best way to implement this safely would be to extend the JWT claims map. Maybe do something like:

{
  "type":"RS512",
  "key": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDdlatRjRjogo3WojgGHFHYLugd\nUWAY9iR3fy4arWNA1KoS8kVw33cJibXr8bvwUAUparCwlvdbH6dvEOfou0/gCFQs\nHUfQrSDv+MuSUMAe8jzKE4qW+jK+xQU9a03GUnKHkkle+Q0pX/g6jXZ7r1/xAK5D\no2kQ+X5xK9cipRgEKwIDAQAB\n-----END PUBLIC KEY-----\n",
  "claims_map": {
    "x-hasura-allowed-roles": ["user","editor"],
    "x-hasura-default-role": "user",
    "x-hasura-user-id": {"path":"$.user.id"},
    "x-hasura-team-id": {"allow_header": true},
  }
}

This would be a huge help to us!

jesse-savary avatar Jan 20 '21 03:01 jesse-savary

Hey, I also have a use case for this.

I have a public API which serves localized content. I would like to pass X-Hasura-Language as a header and then use it from session variables to return localized content to the user. In the case of authenticated users, I am able to save this in the JWT. This also has downsides since user will need to issue new jwt tokens if they change their language. In this use case it would be nice if the client can freely set this header to any language they wish.

vileanco avatar Feb 26 '21 10:02 vileanco

You can refresh their client token if they change their language.

On Fri, Feb 26, 2021 at 5:32 AM Ville Nukarinen [email protected] wrote:

Hey, I also have a use case for this.

I have a public API which serves localized content. I would like to pass X-Hasura-Language as a header and then use it from session variables to return localized content to the user. In the case of authenticated users, I am able to save this in the JWT. This also has downsides since user will need to issue new jwt tokens if they change their language. In this use case it would be nice if the client can freely set this header to any language they wish.

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://urldefense.proofpoint.com/v2/url?u=https-3A__github.com_hasura_graphql-2Dengine_issues_1601-23issuecomment-2D786560821&d=DwMCaQ&c=slrrB7dE8n7gBJbeO0g-IQ&r=8_YpxMTBaJijwQRqgmU3BA&m=m8mEnrHJLHI__Nl6LqZdRP8ETWpYxjxxIsuGxt9g1NE&s=D-vbzl12-ryb5Z3-_bL8-xfQXd-t9x200XG04zZ9QIo&e=, or unsubscribe https://urldefense.proofpoint.com/v2/url?u=https-3A__github.com_notifications_unsubscribe-2Dauth_ACTJTZ3X2TLEDTOIOCBH7VLTA52C7ANCNFSM4GXJAZFQ&d=DwMCaQ&c=slrrB7dE8n7gBJbeO0g-IQ&r=8_YpxMTBaJijwQRqgmU3BA&m=m8mEnrHJLHI__Nl6LqZdRP8ETWpYxjxxIsuGxt9g1NE&s=S44DJyUenS-_9ZvT72XMNyDoGCLK5clkQFcMb-qU5_4&e= .

aroraenterprise avatar Feb 26 '21 16:02 aroraenterprise

You can refresh their client token if they change their language.

I am wondering how should I use this with JWT authentication and using HASURA_GRAPHQL_UNAUTHORIZED_ROLE environment variable for unauthenticated users? Is there any way to set "x-hasura-language" header for unauthenticated users? As these users don't have a token at all.

vileanco avatar Mar 04 '21 13:03 vileanco

For unauthed clients I believe you can just pass it as a normal HTTP header. But I might be misremembering.

rossng avatar Mar 04 '21 15:03 rossng

Our use case is the single row ID one like @morriq and related to the granular permissions / disabling endpoints issues like https://github.com/hasura/graphql-engine/issues/3436

Looking deeper I don't think just disabling the bulk list endpoints would be a secure solution for us - we'd still need to be able to check a row ID header in permissions. Use case:

We have a public shopping cart system. Rather than storing cart contents in browser storage, we anonymously persist it in the database and the browser just stores the basket ID. Currently (with webhook auth) we send an X-Hasura-Basket-Id header which is checked in the permissions of the basket table and every related table, e.g. line_items, bookings, vouchers, etc; everything that can be added to a basket requires you to know the ID of a valid basket.

It feels much more concrete to define the security of individual records (through permissions a row ID header) rather than the almost "security through obscurity" approach of having to disable every bulk end point that could potentially be abused to access anonymous records through every possible relationship both now and into the future. There are exponentially many routes to the data to disable as our app grows, VS definitively defining permissions once per record.


Workaround

We could do basically what @magus suggests and give every user who has a basket a JWT with "public" role and include the basket ID in that JWT. It's not necessary to hijack X-Hasura-User-Id - you can add any X-Hasura- prefixed "headers" to a JWT so we would add X-Hasura-Basket-Id to the JWT as we do with webhook auth.

On the plus side it does feel in the spirit of JWTs, they are designed to be flexible and extensible for this kind of thing. It's just a lot more plumbing to set up. Currently we only generate JWTs for logged in users and default everyone else to "public". We would have to also set public JWTs when performing basket Actions through a slightly different mechanism. Feels messy with more code for bugs to creep in.

sfcgeorge avatar Jul 21 '21 13:07 sfcgeorge

+1

We also want to implement team switching functionality.

  1. I'd like to send x-hasura-team-id header
  2. I'll validate value of this header via permission check if user is indeed a member of this team
  3. then I'll filter out results based on the team.

Options mentioned earlier by @0x777 are acceptable

  1. Global like you suggested, take a regex/list of headers (via server flag or environment variable)
  2. Per user, by making it part of the authorization, i.e, jwt or webhook

poul-kg avatar Oct 08 '21 17:10 poul-kg

Any updates on this? How can we help?

sincraianul avatar Oct 21 '21 09:10 sincraianul

Any update on this?

showgroundtest avatar Nov 06 '21 08:11 showgroundtest

We have following use case for this feature. We have mutilanguage websites, and to simplify queries we use x-hasura-language x-hasura-country as implicit parameters for search, and fields

As an example one of our real function.

CREATE OR REPLACE FUNCTION places_label(places_row places, hasura_session json)
 RETURNS text
 LANGUAGE sql
 STABLE
AS $function$
SELECT
 CASE
 WHEN hasura_session ->> 'x-hasura-website-language' = 'de' THEN places_row.label_de
 WHEN hasura_session ->> 'x-hasura-website-language' = 'en' THEN places_row.label_en
 WHEN hasura_session ->> 'x-hasura-website-language' = 'es' THEN places_row.label_es
 WHEN hasura_session ->> 'x-hasura-website-language' = 'fr' THEN places_row.label_fr
 WHEN hasura_session ->> 'x-hasura-website-language' = 'it' THEN places_row.label_it
 ELSE places_row.label_en
END
$function$;

This highly simplify our queries as instead of quering like below (or explicitly using language variable)

query {
   someData {
       label_de
       label_en
      ...
   }
}

we just write

query {
   someData {
       label
   }
}

And be sure for multilanguage sites this is real simplification. I don't mind about current language variables, does search or filed language/country dependent etc.

All was ok when users were anonymous but now we need to implement jwt.

The issue that user can freely change language, country and this is not user data, its just website behaviour.

Ability to pass headers not through JWT would be very useful for us.

istarkov avatar Nov 16 '21 21:11 istarkov

You can use auth hook instead of hasura jwt directly, to verify the jwt in the hook instead then pass whatever headers you want to from the client to hasura. Your auth hook can check for optional custom non-jwt value headers then attach them.

On Wed, 17 Nov 2021 at 12:55 am, Ivan Starkov @.***> wrote:

We have following use case for this feature. We have mutilanguage websites, and to simplify queries we use x-hasura-language x-hasura-country as implicit parameters for search, and fields

As an exampleone of our real function.

CREATE OR REPLACE FUNCTION places_label(places_row places, hasura_session json) RETURNS text LANGUAGE sql STABLEAS $function$SELECT CASE WHEN hasura_session ->> 'x-hasura-website-language' = 'de' THEN places_row.label_de WHEN hasura_session ->> 'x-hasura-website-language' = 'en' THEN places_row.label_en WHEN hasura_session ->> 'x-hasura-website-language' = 'es' THEN places_row.label_es WHEN hasura_session ->> 'x-hasura-website-language' = 'fr' THEN places_row.label_fr WHEN hasura_session ->> 'x-hasura-website-language' = 'it' THEN places_row.label_it ELSE places_row.label_en END $function$;

This highly simplify out queries as instead of quering like below (or explicitly using language variable)

query { someData { label_de label_en ... } }

we just write

query { someData { label } }

And be sure for multilanguage sites this is real simplification. I don't mind about current language variables, does search or filed language/country dependent etc.

All was ok when users were anonymous but now we need to implement jwt.

The issue that user can freely change language, country and this is not user data, its just website behaviour.

Ability to pass headers not through JWT would be very useful for us.

— You are receiving this because you commented. Reply to this email directly, view it on GitHub https://github.com/hasura/graphql-engine/issues/1601#issuecomment-970713585, or unsubscribe https://github.com/notifications/unsubscribe-auth/ADAZAD5UH7BTB4ULJQU5GZLUMLHO5ANCNFSM4GXJAZFQ . Triage notifications on the go with GitHub Mobile for iOS https://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675 or Android https://play.google.com/store/apps/details?id=com.github.android&referrer=utm_campaign%3Dnotification-email%26utm_medium%3Demail%26utm_source%3Dgithub.

malkhuzayyim avatar Nov 16 '21 22:11 malkhuzayyim