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

Validate all Send Addresses against stellar.expert's list of malicious/unsafe addresses #245

Merged
merged 12 commits into from
Jan 11, 2021
Merged
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@
"trezor-connect": "^8.1.16",
"typescript": "~4.0.5"
},
"resolutions": {
"**/@typescript-eslint/eslint-plugin": "^4.1.1",
"**/@typescript-eslint/parser": "^4.1.1"
},
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I ran into a very weird bug with react-scripts. It was falsely throwing an eslint error in a module exporting an array. What I've done here is kind of a band aid fix: it upgrades the versions of typescript-eslint that react-scripts uses. Another option is to update react-scripts itself BUT that causes a whole host of other eslint issues to crop up which were out of scope of this issue. This was the least invasive method I was able to find.

Further reading here:
https://stackoverflow.com/a/63919395
facebook/create-react-app#9515

Copy link
Contributor

Choose a reason for hiding this comment

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

I haven't seen this one before. I think your fix is good. 👍

Copy link

Choose a reason for hiding this comment

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

Yeah Haven't seen resolutions before but that seems like a useful tool for the toolbox!

"scripts": {
"install-if-package-changed": "git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD | grep --quiet yarn.lock && yarn install || exit 0",
"start": "react-scripts start",
Expand Down
13 changes: 9 additions & 4 deletions src/components/BalanceInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,16 +105,18 @@ const WarningEl = styled.p`
export const BalanceInfo = () => {
const dispatch = useDispatch();
const { account } = useRedux("account");
const { status, data, isAccountWatcherStarted } = account;
const { flaggedAccounts } = useRedux("flaggedAccounts");
const { status: accountStatus, data, isAccountWatcherStarted } = account;
const { status: flaggedAccountsStatus } = flaggedAccounts;
const [isSendTxModalVisible, setIsSendTxModalVisible] = useState(false);
const [isReceiveTxModalVisible, setIsReceiveTxModalVisible] = useState(false);
const publicAddress = data.id;

useEffect(() => {
if (status === ActionStatus.SUCCESS && !isAccountWatcherStarted) {
if (accountStatus === ActionStatus.SUCCESS && !isAccountWatcherStarted) {
dispatch(startAccountWatcherAction(publicAddress));
}
}, [dispatch, publicAddress, status, isAccountWatcherStarted]);
}, [dispatch, publicAddress, accountStatus, isAccountWatcherStarted]);

let nativeBalance = 0;

Expand Down Expand Up @@ -146,7 +148,10 @@ export const BalanceInfo = () => {
logEvent("send: clicked start send");
}}
icon={<IconSend />}
disabled={data.isUnfunded}
disabled={
data.isUnfunded ||
flaggedAccountsStatus !== ActionStatus.SUCCESS
}
>
Send
</Button>
Expand Down
4 changes: 4 additions & 0 deletions src/components/SendTransaction/ConfirmTransaction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { sendTxAction } from "ducks/sendTx";
import { useRedux } from "hooks/useRedux";
import { ActionStatus, AuthType, PaymentFormData } from "types/types.d";

import { IsAccountFlagged } from "./WarningMessages/IsAccountFlagged";

const TableEl = styled.table`
width: 100%;

Expand Down Expand Up @@ -242,6 +244,8 @@ export const ConfirmTransaction = ({
</InfoBlock>
)}

{formData.isAccountUnsafe && <IsAccountFlagged flagType="unsafe" />}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

show unsafe account message on Confirm Transaction.

We don't bother showing the malicious warning as the user should not be able to get to this screen with a malicious destination address


{status === ActionStatus.PENDING &&
settings.authType &&
settings.authType !== AuthType.PRIVATE_KEY && (
Expand Down
37 changes: 36 additions & 1 deletion src/components/SendTransaction/CreateTransaction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import {
import { PALETTE } from "constants/styles";
import { knownAccounts } from "constants/knownAccounts";

import { IsAccountFlagged } from "./WarningMessages/IsAccountFlagged";

const RowEl = styled.div`
display: flex;
flex-wrap: wrap;
Expand Down Expand Up @@ -147,6 +149,11 @@ export const CreateTransaction = ({
initialFormData.isAccountFunded,
);

const [isAccountUnsafe, setIsAccountUnsafe] = useState(
initialFormData.isAccountUnsafe,
);
const [isAccountMalicious, setIsAccountMalicious] = useState(false);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

use initialFormData to populate isAccountSafe to persist the data

We don't bother with isAccountMalicious as a user should be stuck on this screen if the address is malicious


const knownAccount =
knownAccounts[toAccountId] || knownAccounts[federationAddress || ""];
const [prevAddress, setPrevAddress] = useState(
Expand Down Expand Up @@ -251,6 +258,19 @@ export const CreateTransaction = ({
}
};

const { flaggedAccounts } = useRedux("flaggedAccounts");

const checkIfAccountIsFlagged = (accountId: string) => {
const flaggedTags = flaggedAccounts.data.reduce(
(prev: string[], { address, tags }) => {
return address === accountId ? [...prev, ...tags] : prev;
},
[],
);
setIsAccountUnsafe(flaggedTags.includes("unsafe"));
setIsAccountMalicious(flaggedTags.includes("malicious"));
};

const checkAndSetIsAccountFunded = async (accountId: string) => {
if (!accountId || !StrKey.isValidEd25519PublicKey(accountId)) {
setIsAccountFunded(true);
Expand Down Expand Up @@ -411,6 +431,7 @@ export const CreateTransaction = ({
memoType,
memoContent,
isAccountFunded,
isAccountUnsafe,
});
}
};
Expand All @@ -420,7 +441,9 @@ export const CreateTransaction = ({
headlineText="Send Lumens"
buttonFooter={
<>
<Button onClick={onSubmit}>Continue</Button>
<Button disabled={isAccountMalicious} onClick={onSubmit}>
Copy link
Contributor Author

Choose a reason for hiding this comment

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

disable the Continue button if isAccountMalicious

Continue
</Button>
<Button onClick={onCancel} variant={ButtonVariant.secondary}>
Cancel
</Button>
Expand Down Expand Up @@ -478,6 +501,7 @@ export const CreateTransaction = ({

setPrevAddress(e.target.value);
setIsAccountIdTouched(false);
checkIfAccountIsFlagged(e.target.value);
Copy link
Contributor

Choose a reason for hiding this comment

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

We probably want to reset this onChange event.

}}
error={inputErrors[SendFormIds.SEND_TO]}
value={toAccountId}
Expand Down Expand Up @@ -515,6 +539,17 @@ export const CreateTransaction = ({
</RowEl>
)}

{isAccountUnsafe && (
piyalbasu marked this conversation as resolved.
Show resolved Hide resolved
<RowEl>
<IsAccountFlagged flagType="unsafe" />
</RowEl>
)}
{isAccountMalicious && (
<RowEl>
<IsAccountFlagged flagType="malicious" />
</RowEl>
)}

<RowEl>
<CellEl>
<Input
Expand Down
1 change: 1 addition & 0 deletions src/components/SendTransaction/SendTransactionFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const initialFormData: PaymentFormData = {
memoType: MemoNone,
memoContent: "",
isAccountFunded: true,
isAccountUnsafe: false,
};

export const SendTransactionFlow = ({ onCancel }: { onCancel: () => void }) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React from "react";

import { InfoBlock, InfoBlockVariant } from "components/basic/InfoBlock";

export const IsAccountFlagged = ({ flagType = "" }) => (
<InfoBlock variant={InfoBlockVariant.error}>
<p>This account has been flagged as being potentially {flagType}.</p>
</InfoBlock>
);
2 changes: 2 additions & 0 deletions src/config/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import BigNumber from "bignumber.js";
import { RESET_STORE_ACTION_TYPE } from "constants/settings";

import { reducer as account } from "ducks/account";
import { reducer as flaggedAccounts } from "ducks/flaggedAccounts";
import { reducer as keyStore } from "ducks/keyStore";
import { reducer as sendTx } from "ducks/sendTx";
import { reducer as settings } from "ducks/settings";
Expand All @@ -36,6 +37,7 @@ const isSerializable = (value: any) =>

const reducers = combineReducers({
account,
flaggedAccounts,
keyStore,
sendTx,
settings,
Expand Down
2 changes: 2 additions & 0 deletions src/constants/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import StellarSdk from "stellar-sdk";
export const TX_HISTORY_LIMIT = 100;
export const TX_HISTORY_MIN_AMOUNT = 0.5;
export const RESET_STORE_ACTION_TYPE = "RESET";
export const FLAGGED_ACCOUNT_STORAGE_ID = "flaggedAcounts";
export const FLAGGED_ACCOUNT_DATE_STORAGE_ID = "flaggedAcountDate";

interface NetworkItemConfig {
url: string;
Expand Down
72 changes: 72 additions & 0 deletions src/ducks/flaggedAccounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
Copy link
Contributor Author

Choose a reason for hiding this comment

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

moving flaggedAccounts into redux to leverage PENDING and SUCCESS action states to prevent UI from loading before we have all the data we need


import {
FLAGGED_ACCOUNT_STORAGE_ID,
FLAGGED_ACCOUNT_DATE_STORAGE_ID,
} from "constants/settings";
import { getFlaggedAccounts } from "helpers/getFlaggedAccounts";
import { ActionStatus, FlaggedAccounts } from "types/types.d";

const initialState: FlaggedAccounts = {
data: [{ address: "", tags: [""] }],
status: undefined,
};

export const fetchFlaggedAccountsAction = createAsyncThunk(
"action/fetchFlaggedAccountsAction",
async () => {
let accounts;
const date = new Date();
const time = date.getTime();
const sevenDaysAgo = time - 7 * 24 * 60 * 60 * 1000;
const flaggedAccountsCacheDate = Number(
localStorage.getItem(FLAGGED_ACCOUNT_DATE_STORAGE_ID),
);

// if flaggedAccounts were last cached over seven days ago, make the request
// flaggedAccountsCacheDate is coerced to 0 if not found in storage
if (flaggedAccountsCacheDate < sevenDaysAgo) {
try {
accounts = await getFlaggedAccounts();
// store the accounts plus the date we've acquired them
localStorage.setItem(
FLAGGED_ACCOUNT_STORAGE_ID,
JSON.stringify(accounts),
);
localStorage.setItem(FLAGGED_ACCOUNT_DATE_STORAGE_ID, time.toString());
} catch (e) {
// in case of error, try to use what's in localStorage, even if it's old
accounts = JSON.parse(
localStorage.getItem(FLAGGED_ACCOUNT_STORAGE_ID) || "[]",
);
}
} else {
// otherwise, simply use what we have in localStorage to prevent an unnecessary request
accounts = JSON.parse(
localStorage.getItem(FLAGGED_ACCOUNT_STORAGE_ID) || "[]",
);
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you think it would work if we returned the below line as the default value and then change it only if new fetch was successful? Those two lines are exactly the same.

accounts = JSON.parse(
  localStorage.getItem(FLAGGED_ACCOUNT_STORAGE_ID) || "[]",
);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea!

}

return accounts;
},
);

const flaggedAccountsSlice = createSlice({
name: "flaggedAccounts",
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(
fetchFlaggedAccountsAction.pending,
(state = initialState) => {
state.status = ActionStatus.PENDING;
},
);
builder.addCase(fetchFlaggedAccountsAction.fulfilled, (state, action) => {
state.status = ActionStatus.SUCCESS;
state.data = action.payload;
});
},
});

export const { reducer } = flaggedAccountsSlice;
25 changes: 25 additions & 0 deletions src/helpers/getFlaggedAccounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
const UNSAFE_ACCOUNTS_URL =
"https://api.stellar.expert/explorer/directory?limit=20000000&tag[]=malicious&tag[]=unsafe";
// setting limit very high as there doesn't appear to be a better way to get all entries from API

const RESPONSE_TIMEOUT = 5000;
// if API doesn't respond in this amount of time, we'll cancel the request

export const getFlaggedAccounts = async () => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), RESPONSE_TIMEOUT);

const flaggedAccountsRes = await fetch(UNSAFE_ACCOUNTS_URL, {
signal: controller.signal,
});
clearTimeout(timeoutId);
const flaggedAccountsJson = await flaggedAccountsRes.json();

const {
_embedded: { records: unsafeAccountsData },
} = flaggedAccountsJson;

return unsafeAccountsData.map(
({ address, tags }: { address: string; tags: [] }) => ({ address, tags }),
);
};
6 changes: 5 additions & 1 deletion src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import React, { useEffect } from "react";
import { useDispatch } from "react-redux";
import styled from "styled-components";
import { BalanceInfo } from "components/BalanceInfo";
import { TransactionHistory } from "components/TransactionHistory";
import { logEvent } from "helpers/tracking";
import { fetchFlaggedAccountsAction } from "ducks/flaggedAccounts";

const WrapperEl = styled.div`
width: 100%;
`;

export const Dashboard = () => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchFlaggedAccountsAction());
Copy link
Contributor Author

Choose a reason for hiding this comment

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

request flagged accounts as soon as dashboard loads

logEvent("page: saw account main screen");
}, []);
}, [dispatch]);

return (
<WrapperEl>
Expand Down
12 changes: 12 additions & 0 deletions src/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@ export interface AccountInitialState {
errorString?: string;
}

interface FlaggedAccount {
address: string;
tags: string[];
}

export interface FlaggedAccounts {
data: [FlaggedAccount];
status: ActionStatus | undefined;
}

export interface KeyStoreInitialState {
keyStoreId: string;
password: string;
Expand Down Expand Up @@ -107,6 +117,7 @@ export interface WalletInitialState {

export interface Store {
account: AccountInitialState;
flaggedAccounts: FlaggedAccounts;
keyStore: KeyStoreInitialState;
knownAccounts: KnownAccountsInitialState;
sendTx: SendTxInitialState;
Expand Down Expand Up @@ -141,4 +152,5 @@ export interface PaymentFormData {
memoType: MemoType;
memoContent: MemoValue;
isAccountFunded: boolean;
isAccountUnsafe: boolean;
}
Loading