Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AN-614: Allowed security scheme must exist in config.json #1102

Merged
merged 8 commits into from
May 19, 2022
6 changes: 6 additions & 0 deletions .changeset/fifty-tools-attack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@api3/airnode-adapter': minor
'@api3/airnode-validator': minor
---

Moves securityScheme reference check to airnode-validator package
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,6 @@ describe('building empty parameters', () => {
});
});

it('returns no parameters if API securitySchemes is empty', () => {
const ois = fixtures.buildOIS();
const apiSpecifications: ApiSpecification = {
...ois.apiSpecifications,
components: {
...ois.apiSpecifications.components,
securitySchemes: {},
},
};
const invalidOIS = fixtures.buildOIS({ apiSpecifications });
const options = fixtures.buildCacheRequestOptions({ ois: invalidOIS });
const res = authentication.buildParameters(options);
expect(res).toEqual({
headers: {},
query: {},
cookies: {},
});
});

it('returns no parameters if API credentials is empty', () => {
const options = fixtures.buildCacheRequestOptions({ apiCredentials: undefined });
const res = authentication.buildParameters(options);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,6 @@ export function buildParameters(options: CachedBuildRequestOptions): Authenticat
options.ois.apiSpecifications.security,
(authentication, _security, apiSecuritySchemeName) => {
const apiSecurityScheme = options.ois.apiSpecifications.components.securitySchemes[apiSecuritySchemeName];
// If there is no security scheme, ignore the scheme
if (!apiSecurityScheme) {
return authentication;
}

const apiCredentials = find(options.apiCredentials, ['securitySchemeName', apiSecuritySchemeName]) ?? null;
return merge(authentication, getSchemeAuthentication(apiSecurityScheme, apiCredentials, options));
},
Expand Down
2 changes: 1 addition & 1 deletion packages/airnode-node/src/api/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ describe('callApi', () => {
},
apiCredentials: [
{
securitySchemeName: 'My Security Scheme',
securitySchemeName: 'myApiSecurityScheme',
securitySchemeValue: 'supersecret',
},
],
Expand Down
2 changes: 1 addition & 1 deletion packages/airnode-node/test/fixtures/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function buildTrigger(overrides?: Partial<Trigger>): Trigger {

export function buildApiCredentials(overrides?: Partial<ApiCredentials>): ApiCredentials {
return {
securitySchemeName: 'My Security Scheme',
securitySchemeName: 'myApiSecurityScheme',
securitySchemeValue: 'supersecret',
oisTitle: 'Currency Converter API',
...overrides,
Expand Down
2 changes: 1 addition & 1 deletion packages/airnode-node/test/fixtures/config/ois.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function buildOIS(ois?: Partial<OIS>): OIS {
},
components: {
securitySchemes: {
'My Security Scheme': {
myApiSecurityScheme: {
in: 'query',
type: 'apiKey',
name: 'access_key',
Expand Down
7 changes: 2 additions & 5 deletions packages/airnode-validator/src/api/api.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { goSync } from '@api3/promise-utils';
import template from 'lodash/template';
import { z } from 'zod';
import { goSync } from '@api3/promise-utils';
import { SchemaType } from '../types';
import { configSchema } from '../config';
import { Receipt, receiptSchema } from '../receipt';
import { Config, Secrets } from '../types';
import { ValidationResult } from '../validation-result';

type Secrets = Record<string, string | undefined>;
type Config = SchemaType<typeof configSchema>;

/**
* Interpolates `secrets` into `config` and validates the interpolated configuration.
*
Expand Down
46 changes: 43 additions & 3 deletions packages/airnode-validator/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import { readFileSync } from 'fs';
import { join } from 'path';
import { ZodError } from 'zod';
import { chainOptionsSchema, configSchema, nodeSettingsSchema } from './config';
import { Config, SchemaType } from '../types';
import { version as packageVersion } from '../../package.json';
import { SchemaType } from '../types';

it('successfully parses config.json', () => {
const ois = JSON.parse(
const config = JSON.parse(
acenolaza marked this conversation as resolved.
Show resolved Hide resolved
readFileSync(join(__dirname, '../../test/fixtures/interpolated-config.valid.json')).toString()
);
expect(() => configSchema.parse(ois)).not.toThrow();
expect(() => configSchema.parse(config)).not.toThrow();
});

describe('chainOptionsSchema', () => {
Expand Down Expand Up @@ -119,3 +119,43 @@ describe('nodeSettingsSchema', () => {
);
});
});

it('fails if a securitySchemeName is enabled and it is of type "apiKey" or "http" but is missing credentials in "apiCredentials"', () => {
const config: Config = JSON.parse(
readFileSync(join(__dirname, '../../test/fixtures/interpolated-config.valid.json')).toString()
);

const securitySchemeName = 'Currency Converter Security Scheme';
const securityScheme = {
[securitySchemeName]: {
in: 'query',
type: 'apiKey',
name: 'access_key',
},
};
const invalidConfig = {
...config,
ois: [
...config.ois,
{
...config.ois[0],
apiSpecifications: {
...config.ois[0].apiSpecifications,
components: { securitySchemes: securityScheme },
security: { [securitySchemeName]: [] },
},
},
],
apiCredentials: [],
};

expect(() => configSchema.parse(invalidConfig)).toThrow(
new ZodError([
{
code: 'custom',
message: 'The security scheme is enabled but no credentials are provided in "apiCredentials"',
path: ['ois', 1, 'apiSpecifications', 'security', securitySchemeName],
},
])
);
});
44 changes: 35 additions & 9 deletions packages/airnode-validator/src/config/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from 'zod';
import { oisSchema } from '../ois';
import { SuperRefinement, z } from 'zod';
import { version as packageVersion } from '../../package.json';
import { oisSchema } from '../ois';
import { ApiCredentials, OIS } from '../types';

export const triggerSchema = z.object({
endpointId: z.string(),
Expand Down Expand Up @@ -165,10 +166,35 @@ export const apiCredentialsSchema = baseApiCredentialsSchema.extend({
oisTitle: z.string(),
});

export const configSchema = z.object({
chains: z.array(chainConfigSchema),
nodeSettings: nodeSettingsSchema,
ois: z.array(oisSchema),
triggers: triggersSchema,
apiCredentials: z.array(apiCredentialsSchema),
});
const validateSecuritySchemesReferences: SuperRefinement<{
ois: OIS[];
apiCredentials: ApiCredentials[];
}> = (config, ctx) => {
config.ois.forEach((ois, index) => {
Object.keys(ois.apiSpecifications.security).forEach((enabledSecuritySchemeName) => {
acenolaza marked this conversation as resolved.
Show resolved Hide resolved
const enabledSecurityScheme = ois.apiSpecifications.components.securitySchemes[enabledSecuritySchemeName];
if (enabledSecurityScheme && ['apiKey', 'http'].includes(enabledSecurityScheme.type)) {
const securitySchemeApiCredentials = config.apiCredentials.find(
(apiCredentials) => apiCredentials.securitySchemeName === enabledSecuritySchemeName
);
if (!securitySchemeApiCredentials) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `The security scheme is enabled but no credentials are provided in "apiCredentials"`,
path: ['ois', index, 'apiSpecifications', 'security', enabledSecuritySchemeName],
acenolaza marked this conversation as resolved.
Show resolved Hide resolved
});
}
}
});
});
};

export const configSchema = z
.object({
chains: z.array(chainConfigSchema),
nodeSettings: nodeSettingsSchema,
ois: z.array(oisSchema),
triggers: triggersSchema,
apiCredentials: z.array(apiCredentialsSchema),
})
.superRefine(validateSecuritySchemesReferences);
24 changes: 24 additions & 0 deletions packages/airnode-validator/src/ois/ois.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,27 @@ it('verifies parameter interpolation in "apiSpecification.paths"', () => {
])
);
});

it('fails if apiSpecifications.security.<securitySchemeName> is not defined in apiSpecifications.components.<securitySchemeName>', () => {
const invalidSecuritySchemeName = 'INVALID_SECURITY_SCHEME_NAME';
const ois = loadOisFixture();
const invalidOis = {
...ois,
...{
apiSpecifications: {
...ois.apiSpecifications,
security: { ...ois.apiSpecifications.security, [invalidSecuritySchemeName]: [] },
},
},
};

expect(() => oisSchema.parse(invalidOis)).toThrow(
new ZodError([
{
code: 'custom',
message: `Security scheme "${invalidSecuritySchemeName}" is not defined in "components.securitySchemes"`,
path: ['apiSpecifications', 'security', 1],
},
])
);
});
35 changes: 25 additions & 10 deletions packages/airnode-validator/src/ois/ois.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { z } from 'zod';
import intersection from 'lodash/intersection';
import forEach from 'lodash/forEach';
import intersection from 'lodash/intersection';
import trimEnd from 'lodash/trimEnd';
import trimStart from 'lodash/trimStart';
import { SchemaType, ValidatorRefinement } from '../types';
import { z } from 'zod';
import { OIS, SchemaType, ValidatorRefinement } from '../types';

function removeBraces(value: string) {
return trimEnd(trimStart(value, '{'), '}');
Expand Down Expand Up @@ -154,12 +154,27 @@ const ensurePathParametersExist: ValidatorRefinement<SchemaType<typeof pathsSche

export const pathsSchema = z.record(pathSchema).superRefine(ensurePathParametersExist);

export const apiSpecificationSchema = z.object({
components: apiComponentsSchema,
paths: pathsSchema,
servers: z.array(serverSchema),
security: z.record(z.tuple([])),
});
export const apiSpecificationSchema = z
.object({
components: apiComponentsSchema,
paths: pathsSchema,
servers: z.array(serverSchema),
security: z.record(z.tuple([])),
})
.superRefine((apiSpecifications, ctx) => {
Object.keys(apiSpecifications.security).forEach((enabledSecuritySchemeName, index) => {
// Verify that ois.apiSpecifications.security.<securitySchemeName> is
// referencing a valid ois.apiSpecifications.components.<securitySchemeName> object
const enabledSecurityScheme = apiSpecifications.components.securitySchemes[enabledSecuritySchemeName];
if (!enabledSecurityScheme) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Security scheme "${enabledSecuritySchemeName}" is not defined in "components.securitySchemes"`,
path: ['security', index],
});
}
});
});

export const processingSpecificationSchema = z.object({
environment: z.union([z.literal('Node 14'), z.literal('Node 14 async')]),
Expand Down Expand Up @@ -203,5 +218,5 @@ export const baseOisSchema = z.object({
apiSpecifications: apiSpecificationSchema,
endpoints: z.array(endpointSchema),
});
type OIS = SchemaType<typeof baseOisSchema>;

export const oisSchema = baseOisSchema.superRefine(ensureSingleParameterUsagePerEndpoint);
10 changes: 9 additions & 1 deletion packages/airnode-validator/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { ZodFirstPartySchemaTypes, RefinementCtx, z } from 'zod';
import { RefinementCtx, z, ZodFirstPartySchemaTypes } from 'zod';
import { apiCredentialsSchema, configSchema } from './config';
import { oisSchema } from './ois';

export type SchemaType<Schema extends ZodFirstPartySchemaTypes> = z.infer<Schema>;

export type ValidatorRefinement<T> = (arg: T, ctx: RefinementCtx) => void;

export type Secrets = Record<string, string | undefined>;

export type Config = SchemaType<typeof configSchema>;
export type OIS = SchemaType<typeof oisSchema>;
export type ApiCredentials = SchemaType<typeof apiCredentialsSchema>;