Using search applications with untrusted clients
editUsing search applications with untrusted clients
editWhen building a frontend application for search use cases, there are two main approaches to returning search results:
- The client (user’s browser) makes API requests to the application backend, which in turn makes a request to Elasticsearch. The Elasticsearch cluster is not exposed to the end user.
- The client (user’s browser) makes API requests directly to the search service - in this case the Elasticsearch cluster is reachable to the client.
This guide describes best practices when taking the second approach. Specifically, we will explain how to use search applications with frontend apps that make direct requests to the Search Application Search API.
This approach has a few advantages:
- No need to maintain a passthrough query system between frontend applications and Elasticsearch
- Direct requests to Elasticsearch result in faster response times
- Query configuration is managed in one place: your search application configuration in Elasticsearch
We will cover:
Using Elasticsearch API keys with role restrictions
editWhen frontend applications can make direct API requests to Elasticsearch, it’s important to limit the operations they can perform. In our case, frontend applications should only be able to call the Search Application Search API. To ensure this, we will create Elasticsearch API keys with role restrictions. A role restriction is used to specify under what conditions a role should be effective.
The following Elasticsearch API key has access to the website-product-search
search application, only through the Search Application Search API:
resp = client.security.create_api_key( name="my-restricted-api-key", expiration="7d", role_descriptors={ "my-restricted-role-descriptor": { "indices": [ { "names": [ "website-product-search" ], "privileges": [ "read" ] } ], "restriction": { "workflows": [ "search_application_query" ] } } }, ) print(resp)
const response = await client.security.createApiKey({ name: "my-restricted-api-key", expiration: "7d", role_descriptors: { "my-restricted-role-descriptor": { indices: [ { names: ["website-product-search"], privileges: ["read"], }, ], restriction: { workflows: ["search_application_query"], }, }, }, }); console.log(response);
POST /_security/api_key { "name": "my-restricted-api-key", "expiration": "7d", "role_descriptors": { "my-restricted-role-descriptor": { "indices": [ { "names": ["website-product-search"], "privileges": ["read"] } ], "restriction": { "workflows": ["search_application_query"] } } } }
|
|
|
It is crucial to specify the workflow restriction.
Without this the Elasticsearch API key can directly call _search
and issue arbitrary Elasticsearch queries.
This is insecure when dealing with untrusted clients.
The response will look like this:
{ "id": "v1CCJYkBvb5Pg9T-_JgO", "name": "my-restricted-api-key", "expiration": 1689156288526, "api_key": "ztVI-1Q4RjS8qFDxAVet5w", "encoded": "djFDQ0pZa0J2YjVQZzlULV9KZ086enRWSS0xUTRSalM4cUZEeEFWZXQ1dw" }
The encoded value can then be directly used in the Authorization header. Here’s an example using cURL:
curl -XPOST "http://localhost:9200/_application/search_application/website-product-search/_search" \ -H "Content-Type: application/json" \ -H "Authorization: ApiKey djFDQ0pZa0J2YjVQZzlULV9KZ086enRWSS0xUTRSalM4cUZEeEFWZXQ1dw" \ -d '{ "params": { "field_name": "color", "field_value": "red", "agg_size": 5 } }'
If expiration
is not present, by default Elasticsearch API keys never expire.
The API key can be invalidated using the invalidate API key API.
Elasticsearch API keys with role restrictions can also use field and document level security. This further limits how frontend applications query a search application.
Parameter validation with search applications
editYour search applications use search templates to render queries. The template parameters are passed to the Search Application Search API. In the case of APIs used by frontend applications or untrusted clients, we need to have strict parameter validation. Search applications define a JSON schema that describes which parameters the Search Application Search API allows.
The following example defines a search application with strict parameter validation:
resp = client.search_application.put( name="website-product-search", search_application={ "indices": [ "website-products" ], "template": { "script": { "source": { "query": { "term": { "{{field_name}}": "{{field_value}}" } }, "aggs": { "color_facet": { "terms": { "field": "color", "size": "{{agg_size}}" } } } }, "params": { "field_name": "product_name", "field_value": "hello world", "agg_size": 5 } }, "dictionary": { "properties": { "field_name": { "type": "string", "enum": [ "name", "color", "description" ] }, "field_value": { "type": "string" }, "agg_size": { "type": "integer", "minimum": 1, "maximum": 10 } }, "required": [ "field_name" ], "additionalProperties": False } } }, ) print(resp)
const response = await client.searchApplication.put({ name: "website-product-search", search_application: { indices: ["website-products"], template: { script: { source: { query: { term: { "{{field_name}}": "{{field_value}}", }, }, aggs: { color_facet: { terms: { field: "color", size: "{{agg_size}}", }, }, }, }, params: { field_name: "product_name", field_value: "hello world", agg_size: 5, }, }, dictionary: { properties: { field_name: { type: "string", enum: ["name", "color", "description"], }, field_value: { type: "string", }, agg_size: { type: "integer", minimum: 1, maximum: 10, }, }, required: ["field_name"], additionalProperties: false, }, }, }, }); console.log(response);
PUT _application/search_application/website-product-search { "indices": [ "website-products" ], "template": { "script": { "source": { "query": { "term": { "{{field_name}}": "{{field_value}}" } }, "aggs": { "color_facet": { "terms": { "field": "color", "size": "{{agg_size}}" } } } }, "params": { "field_name": "product_name", "field_value": "hello world", "agg_size": 5 } }, "dictionary": { "properties": { "field_name": { "type": "string", "enum": ["name", "color", "description"] }, "field_value": { "type": "string" }, "agg_size": { "type": "integer", "minimum": 1, "maximum": 10 } }, "required": [ "field_name" ], "additionalProperties": false } } }
Using that definition, the Search Application Search API performs the following parameter validation:
-
It only accepts the
field_name
,field_value
andaggs_size
parameters -
field_name
is restricted to only take the values "name", "color" and "description" -
agg_size
defines the size of the term aggregation and it can only take values between1
and10
Working with CORS
editUsing this approach means that your user’s browser will make requests to the Elasticsearch API directly. Elasticsearch supports Cross-Origin Resource Sharing (CORS), but this feature is disabled by default. Therefore the browser will block these requests.
There are two workarounds for this:
Enable CORS on Elasticsearch
editThis is the simplest option.
Enable CORS on Elasticsearch by adding the following to your elasticsearch.yml
file:
http.cors.allow-origin: "*" # Only use unrestricted value for local development # Use a specific origin value in production, like `http.cors.allow-origin: "https://<my-website-domain.example>"` http.cors.enabled: true http.cors.allow-credentials: true http.cors.allow-methods: OPTIONS, POST http.cors.allow-headers: X-Requested-With, X-Auth-Token, Content-Type, Content-Length, Authorization, Access-Control-Allow-Headers, Accept
On Elastic Cloud, you can do this by editing your Elasticsearch user settings.
- From your deployment menu, go to the Edit page.
- In the Elasticsearch section, select Manage user settings and extensions.
- Update the user settings with the configuration above.
- Select Save changes.
Proxy the request through a server that supports CORS
editIf you are unable to enable CORS on Elasticsearch, you can proxy the request through a server that supports CORS. This is more complicated, but is a viable option.