Skip to content

Commit

Permalink
fix(core): Prevent data leakage of guest Customer data
Browse files Browse the repository at this point in the history
Fixes #98
  • Loading branch information
michaelbromley committed Mar 18, 2020
1 parent ec345be commit ea51000
Show file tree
Hide file tree
Showing 7 changed files with 185 additions and 8 deletions.
31 changes: 30 additions & 1 deletion packages/core/e2e/graphql/generated-e2e-shop-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2608,7 +2608,23 @@ export type GetCustomerAddressesQuery = { __typename?: 'Query' } & {
{ __typename?: 'Order' } & {
customer: Maybe<
{ __typename?: 'Customer' } & {
addresses: Maybe<Array<{ __typename?: 'Address' } & Pick<Address, 'id'>>>;
addresses: Maybe<Array<{ __typename?: 'Address' } & Pick<Address, 'id' | 'streetLine1'>>>;
}
>;
}
>;
};

export type GetCustomerOrdersQueryVariables = {};

export type GetCustomerOrdersQuery = { __typename?: 'Query' } & {
activeOrder: Maybe<
{ __typename?: 'Order' } & {
customer: Maybe<
{ __typename?: 'Customer' } & {
orders: { __typename?: 'OrderList' } & {
items: Array<{ __typename?: 'Order' } & Pick<Order, 'id'>>;
};
}
>;
}
Expand Down Expand Up @@ -2850,6 +2866,19 @@ export namespace GetCustomerAddresses {
>;
}

export namespace GetCustomerOrders {
export type Variables = GetCustomerOrdersQueryVariables;
export type Query = GetCustomerOrdersQuery;
export type ActiveOrder = NonNullable<GetCustomerOrdersQuery['activeOrder']>;
export type Customer = NonNullable<(NonNullable<GetCustomerOrdersQuery['activeOrder']>)['customer']>;
export type Orders = (NonNullable<
(NonNullable<GetCustomerOrdersQuery['activeOrder']>)['customer']
>)['orders'];
export type Items = NonNullable<
(NonNullable<(NonNullable<GetCustomerOrdersQuery['activeOrder']>)['customer']>)['orders']['items'][0]
>;
}

export namespace ApplyCouponCode {
export type Variables = ApplyCouponCodeMutationVariables;
export type Mutation = ApplyCouponCodeMutation;
Expand Down
15 changes: 15 additions & 0 deletions packages/core/e2e/graphql/shop-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,21 @@ export const GET_ACTIVE_ORDER_ADDRESSES = gql`
customer {
addresses {
id
streetLine1
}
}
}
}
`;

export const GET_ACTIVE_ORDER_ORDERS = gql`
query GetCustomerOrders {
activeOrder {
customer {
orders {
items {
id
}
}
}
}
Expand Down
111 changes: 108 additions & 3 deletions packages/core/e2e/shop-order.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
GetActiveOrderPayments,
GetAvailableCountries,
GetCustomerAddresses,
GetCustomerOrders,
GetNextOrderStates,
GetOrderByCode,
GetShippingMethods,
Expand All @@ -49,6 +50,7 @@ import {
ADJUST_ITEM_QUANTITY,
GET_ACTIVE_ORDER,
GET_ACTIVE_ORDER_ADDRESSES,
GET_ACTIVE_ORDER_ORDERS,
GET_ACTIVE_ORDER_PAYMENTS,
GET_AVAILABLE_COUNTRIES,
GET_ELIGIBLE_SHIPPING_METHODS,
Expand Down Expand Up @@ -381,10 +383,13 @@ describe('Shop orders', () => {
});

it('customer default Addresses are not updated before payment', async () => {
// TODO: will need to be reworked for https://github.com/vendure-ecommerce/vendure/issues/98
const result = await shopClient.query<GetCustomerAddresses.Query>(GET_ACTIVE_ORDER_ADDRESSES);
const { activeOrder } = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
const { customer } = await adminClient.query<GetCustomer.Query, GetCustomer.Variables>(
GET_CUSTOMER,
{ id: activeOrder!.customer!.id },
);

expect(result.activeOrder!.customer!.addresses).toEqual([]);
expect(customer!.addresses).toEqual([]);
});

it('can transition to ArrangingPayment once Customer has been set', async () => {
Expand Down Expand Up @@ -965,4 +970,104 @@ describe('Shop orders', () => {
expect(activeOrder!.lines[1].productVariant.id).toBe('T_2');
});
});

describe('security of customer data', () => {
let customers: GetCustomerList.Items[];

beforeAll(async () => {
const result = await adminClient.query<GetCustomerList.Query>(GET_CUSTOMER_LIST);
customers = result.customers.items;
});

it('cannot setCustomOrder to existing non-guest Customer', async () => {
await shopClient.asAnonymousUser();
const { addItemToOrder } = await shopClient.query<
AddItemToOrder.Mutation,
AddItemToOrder.Variables
>(ADD_ITEM_TO_ORDER, {
productVariantId: 'T_1',
quantity: 1,
});

try {
await shopClient.query<SetCustomerForOrder.Mutation, SetCustomerForOrder.Variables>(
SET_CUSTOMER,
{
input: {
emailAddress: customers[0].emailAddress,
firstName: 'Evil',
lastName: 'Hacker',
},
},
);
fail('Should have thrown');
} catch (e) {
expect(e.message).toContain(
'Cannot use a registered email address for a guest order. Please log in first',
);
}
const { customer } = await adminClient.query<GetCustomer.Query, GetCustomer.Variables>(
GET_CUSTOMER,
{
id: customers[0].id,
},
);
expect(customer!.firstName).not.toBe('Evil');
expect(customer!.lastName).not.toBe('Hacker');
});

it('guest cannot access Addresses of guest customer', async () => {
await shopClient.asAnonymousUser();
const { addItemToOrder } = await shopClient.query<
AddItemToOrder.Mutation,
AddItemToOrder.Variables
>(ADD_ITEM_TO_ORDER, {
productVariantId: 'T_1',
quantity: 1,
});

await shopClient.query<SetCustomerForOrder.Mutation, SetCustomerForOrder.Variables>(
SET_CUSTOMER,
{
input: {
emailAddress: '[email protected]',
firstName: 'Evil',
lastName: 'Hacker',
},
},
);

const { activeOrder } = await shopClient.query<GetCustomerAddresses.Query>(
GET_ACTIVE_ORDER_ADDRESSES,
);

expect(activeOrder!.customer!.addresses).toEqual([]);
});

it('guest cannot access Orders of guest customer', async () => {
await shopClient.asAnonymousUser();
const { addItemToOrder } = await shopClient.query<
AddItemToOrder.Mutation,
AddItemToOrder.Variables
>(ADD_ITEM_TO_ORDER, {
productVariantId: 'T_1',
quantity: 1,
});

await shopClient.query<SetCustomerForOrder.Mutation, SetCustomerForOrder.Variables>(
SET_CUSTOMER,
{
input: {
emailAddress: '[email protected]',
firstName: 'Evil',
lastName: 'Hacker',
},
},
);

const { activeOrder } = await shopClient.query<GetCustomerOrders.Query>(GET_ACTIVE_ORDER_ORDERS);

expect(activeOrder!.customer!.orders.items).toEqual([]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import { Order } from '../../../entity/order/order.entity';
import { CustomerService } from '../../../service/services/customer.service';
import { OrderService } from '../../../service/services/order.service';
import { UserService } from '../../../service/services/user.service';
import { ApiType } from '../../common/get-api-type';
import { RequestContext } from '../../common/request-context';
import { Api } from '../../decorators/api.decorator';
import { Ctx } from '../../decorators/request-context.decorator';

@Resolver('Customer')
Expand All @@ -19,7 +21,15 @@ export class CustomerEntityResolver {
private userService: UserService,
) {}
@ResolveProperty()
async addresses(@Ctx() ctx: RequestContext, @Parent() customer: Customer): Promise<Address[]> {
async addresses(
@Ctx() ctx: RequestContext,
@Parent() customer: Customer,
@Api() apiType: ApiType,
): Promise<Address[]> {
if (apiType === 'shop' && !ctx.activeUserId) {
// Guest customers should not be able to see this data
return [];
}
return this.customerService.findAddressesByCustomerId(ctx, customer.id);
}

Expand All @@ -28,7 +38,12 @@ export class CustomerEntityResolver {
@Ctx() ctx: RequestContext,
@Parent() customer: Customer,
@Args() args: QueryOrdersArgs,
@Api() apiType: ApiType,
): Promise<PaginatedList<Order>> {
if (apiType === 'shop' && !ctx.activeUserId) {
// Guest customers should not be able to see this data
return { items: [], totalItems: 0 };
}
return this.orderService.findByCustomerId(ctx, customer.id, args.options || undefined);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ export class ShopOrderResolver {
if (ctx.authorizedAsOwnerOnly) {
const sessionOrder = await this.getOrderFromContext(ctx);
if (sessionOrder) {
const customer = await this.customerService.createOrUpdate(args.input);
const customer = await this.customerService.createOrUpdate(args.input, true);
return this.orderService.addCustomerToOrder(ctx, sessionOrder.id, customer);
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"cannot-transition-refund-from-to": "Cannot transition Refund from \"{ fromState }\" to \"{ toState }\"",
"cannot-transition-to-shipping-when-order-is-empty": "Cannot transition Order to the \"ArrangingShipping\" state when it is empty",
"cannot-transition-to-payment-without-customer": "Cannot transition Order to the \"ArrangingPayment\" state without Customer details",
"cannot-use-registered-email-address-for-guest-order": "Cannot use a registered email address for a guest order. Please log in first",
"channel-not-found": "No channel with the token \"{ token }\" exists",
"country-code-not-valid": "The countryCode \"{ countryCode }\" was not recognized",
"coupon-code-expired": "Coupon code \"{ couponCode }\" has expired",
Expand Down
16 changes: 14 additions & 2 deletions packages/core/src/service/services/customer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
import { Connection } from 'typeorm';

import { RequestContext } from '../../api/common/request-context';
import { EntityNotFoundError, InternalServerError, UserInputError } from '../../common/error/errors';
import {
EntityNotFoundError,
IllegalOperationError,
InternalServerError,
UserInputError,
} from '../../common/error/errors';
import { ListQueryOptions } from '../../common/types/common-types';
import { assertFound, idsAreEqual, normalizeEmailAddress } from '../../common/utils';
import { ConfigService } from '../../config/config.service';
Expand Down Expand Up @@ -234,7 +239,10 @@ export class CustomerService {
/**
* For guest checkouts, we assume that a matching email address is the same customer.
*/
async createOrUpdate(input: Partial<CreateCustomerInput> & { emailAddress: string }): Promise<Customer> {
async createOrUpdate(
input: Partial<CreateCustomerInput> & { emailAddress: string },
throwOnExistingUser: boolean = false,
): Promise<Customer> {
input.emailAddress = normalizeEmailAddress(input.emailAddress);
let customer: Customer;
const existing = await this.connection.getRepository(Customer).findOne({
Expand All @@ -243,6 +251,10 @@ export class CustomerService {
},
});
if (existing) {
if (existing.user && throwOnExistingUser) {
// It is not permitted to modify an existing *registered* Customer
throw new IllegalOperationError('error.cannot-use-registered-email-address-for-guest-order');
}
customer = patchEntity(existing, input);
} else {
customer = new Customer(input);
Expand Down

0 comments on commit ea51000

Please sign in to comment.