├── .prettierignore
├── jest.setup.js
├── prettier.config.js
├── index.tsx
├── .eslintrc.json
├── .gitignore
├── tsconfig.json
├── public
└── index.html
├── babel.config.js
├── src
├── components
│ ├── GithubStar.tsx
│ ├── TextHighlight.tsx
│ └── TextInput.tsx
├── utils
│ ├── zeroWidthToUsername.ts
│ └── usernameToZeroWidth.ts
├── __tests__
│ └── journey.test.js
└── App.tsx
├── README.md
├── webpack.config.ts
└── package.json
/.prettierignore:
--------------------------------------------------------------------------------
1 | # Ignore artifacts:
2 | dist
3 |
--------------------------------------------------------------------------------
/jest.setup.js:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
2 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | semi: true,
3 | printWidth: 120,
4 | singleQuote: true,
5 | trailingComma: 'es5',
6 | arrowParens: 'avoid',
7 | };
8 |
--------------------------------------------------------------------------------
/index.tsx:
--------------------------------------------------------------------------------
1 | import 'react-hot-loader';
2 | import React from 'react';
3 | import ReactDOM from 'react-dom';
4 |
5 | import App from './src/App';
6 |
7 | ReactDOM.render( , document.getElementById('root'));
8 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["airbnb", "prettier"],
3 | "plugins": ["prettier"],
4 | "env": {
5 | "browser": true,
6 | "node": true
7 | },
8 | "rules": {
9 | "no-irregular-whitespace": "off"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | /node_modules
3 |
4 | # testing
5 | /coverage
6 |
7 | # production
8 | /build
9 | /dist
10 |
11 | # misc
12 | .DS_Store
13 | .env.local
14 | .env.development.local
15 | .env.test.local
16 | .env.production.local
17 |
18 | npm-debug.log*
19 | yarn-debug.log*
20 | yarn-error.log*
21 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["./*.tsx", "src"],
3 | "compilerOptions": {
4 | "outDir": "./dist/",
5 | "strict": true,
6 | "sourceMap": true,
7 | "declaration": true,
8 | "esModuleInterop": true,
9 | "module": "es6",
10 | "jsx": "react",
11 | "target": "es2019",
12 | "moduleResolution": "node"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Zero Width Detection
7 |
8 |
9 |
10 | You need to enable JavaScript to run this app.
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function (api) {
2 | api.cache(true);
3 |
4 | return {
5 | presets: [
6 | '@babel/typescript',
7 | ['@babel/preset-env', { useBuiltIns: 'usage', corejs: 3 }],
8 | '@babel/preset-react',
9 | [
10 | '@emotion/babel-preset-css-prop',
11 | {
12 | sourceMap: process.env.NODE_ENV !== 'test',
13 | labelFormat: '[filename]-[local]',
14 | },
15 | ],
16 | ],
17 | plugins: ['react-hot-loader/babel'],
18 | };
19 | };
20 |
--------------------------------------------------------------------------------
/src/components/GithubStar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { css } from '@emotion/core';
3 |
4 | const style = css`
5 | width: 150px;
6 | height: 30px;
7 | position: absolute;
8 | top: 25px;
9 | right: 0px;
10 |
11 | @media (max-width: 500px) {
12 | display: none;
13 | }
14 | `;
15 |
16 | export const GithubStar = () => (
17 |
24 | );
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # zero-width-detection
2 |
3 | A project to demonstrate the vulnerabilities of copying text that may have zero-width characters inserted into it.
4 |
5 | [Medium Article](https://medium.com/@umpox/be-careful-what-you-copy-invisibly-inserting-usernames-into-text-with-zero-width-characters-18b4e6f17b66)
6 |
7 | [Demo](https://umpox.com/zero-width-detection)
8 |
9 | ## To run:
10 |
11 | `yarn`
12 |
13 | `yarn dev`
14 |
15 | ## Useful Files
16 |
17 | [src/utils/usernameToZeroWidth.tss](./src/utils/usernameToZeroWidth.ts)
18 |
19 | [src/utils/zeroWidthToUsername.ts](./src/utils/zeroWidthToUsername.ts)
20 |
--------------------------------------------------------------------------------
/src/components/TextHighlight.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { css } from '@emotion/core';
3 |
4 | const style = css`
5 | display: flex;
6 | align-items: center;
7 | justify-content: center;
8 | height: 100px;
9 | background-color: #e8e8e8;
10 | border-radius: 10px;
11 | padding: 20px;
12 | font-size: 20px;
13 | `;
14 |
15 | interface TextHighlightProps {
16 | /** Allows custom styling through Emotion */
17 | className?: string;
18 | testId?: string;
19 | }
20 |
21 | export const TextHighlight: React.FC = ({ children, className, testId }) => (
22 |
25 | );
26 |
--------------------------------------------------------------------------------
/src/utils/zeroWidthToUsername.ts:
--------------------------------------------------------------------------------
1 | const zeroWidthToBinary = (zeroWidthStr: string) =>
2 | zeroWidthStr
3 | .split('')
4 | .map(char => {
5 | // invisible
6 | if (char === '') {
7 | // invisible
8 | return '1';
9 | } else if (char === '') {
10 | // invisible
11 | return '0';
12 | }
13 | return ' '; // split up binary with spaces;
14 | })
15 | .join('');
16 |
17 | const binaryToText = (binaryStr: string) =>
18 | binaryStr
19 | .split(' ')
20 | .map(num => String.fromCharCode(parseInt(num, 2)))
21 | .join('');
22 |
23 | export const zeroWidthToUsername = (zeroWidthUsername: string) => {
24 | const binaryUsername = zeroWidthToBinary(zeroWidthUsername);
25 | const textUsername = binaryToText(binaryUsername);
26 | return textUsername;
27 | };
28 |
--------------------------------------------------------------------------------
/src/components/TextInput.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { css } from '@emotion/core';
3 |
4 | interface TextInputProps {
5 | onChange: (value: string) => void;
6 | placeholder?: string;
7 | ariaLabel?: string;
8 | }
9 |
10 | const style = css`
11 | width: 100%;
12 | padding: 0.3rem 0.5rem;
13 | font-size: 1rem;
14 | line-height: 2;
15 | color: #495057;
16 | background-color: #fff;
17 | background-clip: padding-box;
18 | border: 1px solid #ced4da;
19 | border-radius: 0.25rem;
20 | transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
21 | `;
22 |
23 | export const TextInput: React.FC = ({ onChange, placeholder, ariaLabel = "Username" }) => (
24 | onChange(event.target.value)}
27 | type="text"
28 | placeholder={placeholder}
29 | aria-label={ariaLabel}
30 | />
31 | );
32 |
--------------------------------------------------------------------------------
/src/utils/usernameToZeroWidth.ts:
--------------------------------------------------------------------------------
1 | const zeroPad = (num: string) => '00000000'.slice(num.length) + num;
2 |
3 | const textToBinary = (username: string) =>
4 | username
5 | .split('')
6 | .map(char => zeroPad(char.charCodeAt(0).toString(2)))
7 | .join(' ');
8 |
9 | const binaryToZeroWidth = (binary: string) =>
10 | binary
11 | .split('')
12 | .map(binaryNum => {
13 | const num = parseInt(binaryNum, 10);
14 | if (num === 1) {
15 | return ''; // invisible
16 | } else if (num === 0) {
17 | return ''; // invisible
18 | }
19 | return ''; // invisible
20 | })
21 | .join(''); // invisible
22 |
23 | export const usernameToZeroWidth = (username: string) => {
24 | const binaryUsername = textToBinary(username);
25 | const zeroWidthUsername = binaryToZeroWidth(binaryUsername);
26 | return zeroWidthUsername;
27 | };
28 |
--------------------------------------------------------------------------------
/webpack.config.ts:
--------------------------------------------------------------------------------
1 | import * as path from 'path';
2 |
3 | import webpack from 'webpack';
4 | import HtmlWebpackPlugin from 'html-webpack-plugin';
5 |
6 | const isProduction = process.env.NODE_ENV === 'production';
7 |
8 | const entry = path.resolve(__dirname, 'index.tsx');
9 |
10 | const config: webpack.Configuration = {
11 | mode: isProduction ? 'production' : 'development',
12 | entry,
13 | devtool: 'inline-source-map',
14 | module: {
15 | rules: [
16 | {
17 | test: /\.tsx?$/,
18 | exclude: /node_modules/,
19 | use: 'babel-loader',
20 | },
21 | ],
22 | },
23 | resolve: {
24 | extensions: ['.tsx', '.ts', '.js'],
25 | },
26 | plugins: [
27 | new HtmlWebpackPlugin({
28 | title: 'Zero Width Detection',
29 | template: path.resolve(__dirname, 'public/index.html'),
30 | }),
31 | ],
32 | output: {
33 | filename: 'bundle.js',
34 | path: path.resolve(__dirname, 'dist'),
35 | },
36 | };
37 |
38 | if (!isProduction) {
39 | config.entry = ['webpack-hot-middleware/client', entry];
40 | config.plugins?.push(new webpack.HotModuleReplacementPlugin());
41 | }
42 |
43 | export default config;
44 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "zero-width-detection",
3 | "homepage": "https://umpox.github.io/zero-width-detection",
4 | "version": "0.1.0",
5 | "private": true,
6 | "dependencies": {
7 | "@emotion/core": "^10.0.35",
8 | "react": "^16.2.0",
9 | "react-dom": "^16.2.0"
10 | },
11 | "scripts": {
12 | "test": "jest",
13 | "dev": "webpack serve",
14 | "build": "webpack",
15 | "predeploy": "yarn build",
16 | "deploy": "gh-pages -d dist"
17 | },
18 | "devDependencies": {
19 | "@babel/preset-env": "^7.12.0",
20 | "@babel/preset-react": "^7.10.4",
21 | "@babel/preset-typescript": "^7.12.0",
22 | "@emotion/babel-preset-css-prop": "^10.0.27",
23 | "@testing-library/jest-dom": "^5.11.4",
24 | "@testing-library/react": "^11.1.0",
25 | "@types/react": "^16.9.52",
26 | "@types/react-dom": "^16.9.8",
27 | "babel-loader": "^8.1.0",
28 | "core-js": "^3.6.5",
29 | "eslint-config-airbnb": "^16.1.0",
30 | "eslint-config-prettier": "^6.12.0",
31 | "eslint-plugin-import": "^2.8.0",
32 | "eslint-plugin-prettier": "^3.1.4",
33 | "eslint-plugin-react": "^7.5.1",
34 | "gh-pages": "^1.1.0",
35 | "html-webpack-plugin": "^4.5.0",
36 | "jest": "^26.5.3",
37 | "prettier": "2.1.2",
38 | "react-hot-loader": "^4.13.0",
39 | "typescript": "^4.0.3",
40 | "webpack": "^5.1.1",
41 | "webpack-cli": "^4.0.0",
42 | "webpack-dev-server": "^3.11.0",
43 | "webpack-hot-middleware": "^2.25.0"
44 | },
45 | "jest": {
46 | "setupFilesAfterEnv": [
47 | "/jest.setup.js"
48 | ]
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/__tests__/journey.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, fireEvent, getNodeText } from '@testing-library/react';
3 |
4 | import App from '../App';
5 |
6 | describe('Zero Width Detection Flow', () => {
7 | let queries;
8 |
9 | beforeEach(async () => {
10 | queries = render( );
11 | });
12 |
13 | it.each([
14 | /Enter username/,
15 | /Copy text below/,
16 | /Paste copied text here/,
17 | /Your username is/
18 | ])('renders %s step', (step) => {
19 | expect(queries.queryByText(step)).toBeInTheDocument();
20 | });
21 |
22 | it('username input field is initially empty', () => {
23 | const usernameInput = queries.getByLabelText('Username');
24 | expect(usernameInput.value).toBe('')
25 | });
26 |
27 | it('confidential text input field is initially empty', () => {
28 | const confidentialInput = queries.getByLabelText('Hidden Username');
29 | expect(confidentialInput.value).toBe('')
30 | });
31 |
32 | it('confidential text initially has no zero width characters', () => {
33 | const confidentialText = queries.getByTestId('confidentialText');
34 |
35 | // Match zero width characters
36 | expect(confidentialText.innerHTML.match(/[]/)).toBeNull();
37 | });
38 |
39 | describe('When a user enters text', () => {
40 | const username = 'Tom';
41 |
42 | beforeEach(async () => {
43 | const usernameInput = queries.getByLabelText('Username');
44 | fireEvent.change(usernameInput, { target: { value: username } })
45 | })
46 |
47 | it('injects zero width characters into confidential text', () => {
48 | const confidentialText = queries.getByTestId('confidentialText');
49 |
50 | // Match zero width characters
51 | expect(confidentialText.innerHTML.match(/[]/)).not.toBeNull();
52 | });
53 |
54 | it('converts zero width characters back to username when pasted', () => {
55 | const revealedUsername = queries.getByTestId('revealedUsername');
56 | // Check we have no output first
57 | expect(revealedUsername).not.toHaveTextContent();
58 |
59 | // Copy and paste confidential text into text input
60 | const copiedText = queries.getByTestId('confidentialText').innerHTML;
61 | fireEvent.change(queries.getByLabelText('Hidden Username'), { target: { value: copiedText } })
62 |
63 | // Check the initial username is now visible to the user
64 | expect(revealedUsername).toHaveTextContent('Tom');
65 | expect(queries.queryByText(/Don't believe me?/)).toBeInTheDocument();
66 | });
67 | });
68 | });
69 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { hot } from 'react-hot-loader/root';
2 | import React, { useState } from 'react';
3 | import { css } from '@emotion/core';
4 |
5 | import { GithubStar } from './components/GithubStar';
6 | import { TextInput } from './components/TextInput';
7 | import { TextHighlight } from './components/TextHighlight';
8 | import { usernameToZeroWidth } from './utils/usernameToZeroWidth';
9 | import { zeroWidthToUsername } from './utils/zeroWidthToUsername';
10 |
11 | const styles = {
12 | app: css`
13 | * {
14 | box-sizing: border-box;
15 | font-family: Ubuntu, Arial, Helvetica, sans-serif;
16 | }
17 |
18 | h2 {
19 | font-size: 28px;
20 | }
21 |
22 | height: 100vh;
23 | padding: 5% 10%;
24 | `,
25 | container: css`
26 | height: 100%;
27 | `,
28 | row: css`
29 | position: relative;
30 | min-height: 10%;
31 | padding-top: 25px;
32 | `,
33 | usernameReveal: css`
34 | height: 50px;
35 | justify-content: start;
36 | `,
37 | muted: css`
38 | color: #868e96;
39 |
40 | a {
41 | color: #007bff;
42 | }
43 | `,
44 | };
45 |
46 | const App = () => {
47 | const [hiddenUsername, setHiddenUsername] = useState('');
48 | const [revealedUsername, setRevealedUsername] = useState('');
49 |
50 | const transformUsername = (username: string) => setHiddenUsername(usernameToZeroWidth(username));
51 |
52 | const revealUsername = (username: string) => {
53 | // Replace all text except zero width characters
54 | const zeroWidthUsername = username.replace(/[^]/g, '');
55 |
56 | return setRevealedUsername(zeroWidthToUsername(zeroWidthUsername));
57 | };
58 |
59 | return (
60 |
61 |
62 |
63 |
64 |
1: Enter username:
65 |
66 |
67 |
68 |
69 |
2: Copy text below
70 |
71 | Confidential Announcement: {hiddenUsername}
72 | This is some confidential text that you really shouldn't be sharing anywhere else.
73 |
74 |
75 |
76 |
77 |
3: Paste copied text here:
78 |
79 |
80 |
81 |
82 |
4: Your username is...
83 |
{revealedUsername}
84 | {revealedUsername && (
85 |
86 | Don't believe me? Try pasting the text here again in a different browser or through incognito mode.
87 |
88 | How does this work?
89 |
90 |
91 | )}
92 |
93 |
94 |
95 | );
96 | };
97 |
98 | export default hot(App);
99 |
--------------------------------------------------------------------------------