Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 2 additions & 9 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
---
labels: mergeable
---
[//]: # (Link to the issue corresponding to this chunk of work)
Fixes: #__issue__

## Motivation and Context
[//]: # (Why is this change required? What problem does it solve?)

Expand All @@ -13,6 +7,5 @@ Fixes: #__issue__
## How has this been tested?
[//]: # (Please describe in detail how you tested your changes)


[//]: # (OPTIONAL)
[//]: # (Add one or multiple labels: enhancement, refactoring, bugfix)
## Documentation
[//]: # (Does this PR require documentation updates?)
84 changes: 76 additions & 8 deletions src/events/sdk-key-decoder.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,40 @@
import { Base64 } from 'js-base64';

import SdkKeyDecoder from './sdk-key-decoder';

describe('SdkKeyDecoder', () => {
const decoder = new SdkKeyDecoder();
it('should decode the event ingestion hostname from the SDK key', () => {
const hostname = decoder.decodeEventIngestionUrl(
'zCsQuoHJxVPp895.ZWg9MTIzNDU2LmUudGVzdGluZy5lcHBvLmNsb3Vk',
let decoder: SdkKeyDecoder;
const sdkKeyPrefix = 'zCsQuoHJxVPp895';

beforeEach(() => {
decoder = new SdkKeyDecoder();
});

it('should return null for all URLs when no hosts are encoded', () => {
const sdkKey = 'invalid.sdk.key';
expect(decoder.decodeEventIngestionUrl(sdkKey)).toBeNull();
expect(decoder.decodeAssignmentConfigurationUrl(sdkKey)).toBeNull();
expect(decoder.decodeEdgeConfigurationUrl(sdkKey)).toBeNull();
});

it('should return event ingestion URL when only event ingestion host is encoded', () => {
const sdkKey = `${sdkKeyPrefix}.${Base64.encode('eh=123456.e.testing.eppo.cloud')}.signature`;
expect(decoder.decodeEventIngestionUrl(sdkKey)).toBe(
'https://123456.e.testing.eppo.cloud/v0/i',
);
expect(decoder.decodeAssignmentConfigurationUrl(sdkKey)).toBeNull();
expect(decoder.decodeEdgeConfigurationUrl(sdkKey)).toBeNull();
});

it('should return assignment configuration URL when only configuration host is encoded', () => {
const sdkKey = `${sdkKeyPrefix}.${Base64.encode('ch=123456.c.testing.eppo.cloud')}.signature`;
expect(decoder.decodeEventIngestionUrl(sdkKey)).toBeNull();
expect(decoder.decodeAssignmentConfigurationUrl(sdkKey)).toBe(
'https://123456.c.testing.eppo.cloud/assignment',
);
expect(decoder.decodeEdgeConfigurationUrl(sdkKey)).toBe(
'https://123456.c.testing.eppo.cloud/edge',
);
expect(hostname).toEqual('https://123456.e.testing.eppo.cloud/v0/i');
});

it('should decode strings with non URL-safe characters', () => {
Expand All @@ -17,8 +45,48 @@ describe('SdkKeyDecoder', () => {
expect(hostname).toEqual('https://12 3456/.e.testing.eppo.cloud/v0/i');
});

it("should return null if the SDK key doesn't contain the event ingestion hostname", () => {
expect(decoder.decodeEventIngestionUrl('zCsQuoHJxVPp895')).toBeNull();
expect(decoder.decodeEventIngestionUrl('zCsQuoHJxVPp895.xxxxxx')).toBeNull();
it('should handle malformed SDK keys gracefully', () => {
const malformedKeys = [
'',
'invalid',
'invalid.',
'invalid.invalid',
'invalid.invalid.invalid',
`valid.${Base64.encode('invalid=host')}.signature`,
];

malformedKeys.forEach((key) => {
expect(decoder.decodeEventIngestionUrl(key)).toBeNull();
expect(decoder.decodeAssignmentConfigurationUrl(key)).toBeNull();
expect(decoder.decodeEdgeConfigurationUrl(key)).toBeNull();
});
});

it('should handle URLs with existing schemes', () => {
const hosts = ['eh=http://event.host', 'ch=https://config.host'].join('&');
const sdkKey = `${sdkKeyPrefix}.${Base64.encode(hosts)}.signature`;

expect(decoder.decodeEventIngestionUrl(sdkKey)).toBe('http://event.host/v0/i');
expect(decoder.decodeAssignmentConfigurationUrl(sdkKey)).toBe('https://config.host/assignment');
expect(decoder.decodeEdgeConfigurationUrl(sdkKey)).toBe('https://config.host/edge');
});

it('should add https scheme when protocol is missing', () => {
const hosts = ['eh=event.host', 'ch=config.host:8080'].join('&');
const sdkKey = `${sdkKeyPrefix}.${Base64.encode(hosts)}.signature`;

expect(decoder.decodeEventIngestionUrl(sdkKey)).toBe('https://event.host/v0/i');
expect(decoder.decodeAssignmentConfigurationUrl(sdkKey)).toBe(
'https://config.host:8080/assignment',
);
expect(decoder.decodeEdgeConfigurationUrl(sdkKey)).toBe('https://config.host:8080/edge');
});

it('should handle special characters in URLs', () => {
const specialHost = 'eh=test.host/with+special@chars?param=value';
const sdkKey = `${sdkKeyPrefix}.${Base64.encode(specialHost)}.signature`;
expect(decoder.decodeEventIngestionUrl(sdkKey)).toBe(
'https://test.host/with special@chars?param=value/v0/i',
);
});
});
65 changes: 54 additions & 11 deletions src/events/sdk-key-decoder.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,70 @@
import { Base64 } from 'js-base64';

const PATH = 'v0/i';
const EVENT_INGESTION_HOSTNAME_KEY = 'eh';
const CONFIGURATION_HOSTNAME_KEY = 'ch';
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@typotter Since I got here first 😉 , I selected ch (for configuration host). Please reflect this with the server changes.


const EVENT_INGESTION_PATH = 'v0/i';
const ASSIGNMENT_CONFIG_PATH = 'assignment';
const EDGE_CONFIG_PATH = 'edge';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems this is intended to follow the same pattern used for event ingestion. The EVENT_INGESTION_PATH is used to return the full path/URL to be used. This doesn't seem to be doing that. I'd recommend we put the full path here in this file to be consistent.


export default class SdkKeyDecoder {
/**
* Decodes and returns the event ingestion hostname from the provided Eppo SDK key string.
* If the SDK key doesn't contain the event ingestion hostname, or it's invalid, it returns null.
*/
decodeEventIngestionUrl(sdkKey: string): string | null {
return this.decodeHostnames(sdkKey, EVENT_INGESTION_PATH).eventIngestionHostname;
}

/**
* Decodes and returns the configuration hostname from the provided Eppo SDK key string.
* If the SDK key doesn't contain the configuration hostname, or it's invalid, it returns null.
*/
decodeAssignmentConfigurationUrl(sdkKey: string): string | null {
return this.decodeHostnames(sdkKey, ASSIGNMENT_CONFIG_PATH).configurationHostname;
}

/**
* Decodes and returns the edge configuration hostname from the provided Eppo SDK key string.
* If the SDK key doesn't contain the edge configuration hostname, or it's invalid, it returns null.
*/
decodeEdgeConfigurationUrl(sdkKey: string): string | null {
return this.decodeHostnames(sdkKey, EDGE_CONFIG_PATH).configurationHostname;
}
Comment on lines +19 to +33
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking into the future a bit: the clients allow for a baseUrl to be passed in; however we will no longer allow this to be fscdn.eppo.cloud if a hostname is decoded here as non-null and it will thrown an exception otherwise people will be able to bypass our DNS blocks. Down the line once all SDK keys have rotated or some future date, we can enforce a subdomain to be present and block bare requests to fscdn.eppo.cloud in VCL.


private decodeHostnames(
sdkKey: string,
path: string,
): {
eventIngestionHostname: string | null;
configurationHostname: string | null;
} {
const encodedPayload = sdkKey.split('.')[1];
if (!encodedPayload) return null;
if (!encodedPayload) return { eventIngestionHostname: null, configurationHostname: null };

const decodedPayload = Base64.decode(encodedPayload);
const params = new URLSearchParams(decodedPayload);
const hostname = params.get('eh');
if (!hostname) return null;

const hostAndPath = hostname.endsWith('/') ? `${hostname}${PATH}` : `${hostname}/${PATH}`;
if (!hostAndPath.startsWith('http://') && !hostAndPath.startsWith('https://')) {
// prefix hostname with https scheme if none present
return `https://${hostAndPath}`;
} else {
return hostAndPath;
const eventIngestionHostname = params.get(EVENT_INGESTION_HOSTNAME_KEY);
const configurationHostname = params.get(CONFIGURATION_HOSTNAME_KEY);

return {
eventIngestionHostname: eventIngestionHostname
? this.ensureHttps(this.ensurePath(eventIngestionHostname, path))
: null,
configurationHostname: configurationHostname
? this.ensureHttps(this.ensurePath(configurationHostname, path))
: null,
};
}

private ensureHttps(hostname: string): string {
if (!hostname.startsWith('http://') && !hostname.startsWith('https://')) {
return `https://${hostname}`;
}
return hostname;
}

private ensurePath(hostname: string, path: string): string {
return hostname.endsWith('/') ? `${hostname}${path}` : `${hostname}/${path}`;
}
}
Loading