1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-04-21 13:51:59 +02:00

implement scrubber for end user data in exceptions (#4999)

This commit is contained in:
Alexander Campbell 2022-05-26 15:44:22 -06:00 committed by GitHub
parent 25be3b7031
commit fe13a8f9e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 202 additions and 36 deletions

View File

@ -0,0 +1,7 @@
---
'slate': minor
'slate-react': minor
---
Add new Slate.Scrubber interface to allow scrubbing end user data from exception
text. The default behavior remains unchanged.

View File

@ -48,6 +48,7 @@
- [RangeRef](api/locations/range-ref.md)
- [Span](api/locations/span.md)
- [Operation](api/operation.md)
- [Scrubber](api/scrubber.md)
## Libraries

78
docs/api/scrubber.md Normal file
View File

@ -0,0 +1,78 @@
# Scrubber API
When Slate throws an exception, it includes a stringified representation of the
relevant data. For example, if your application makes an API call to access the
child of a text Node (an impossible operation), Slate will throw an exception
like this:
```
Cannot get the child of a text node: {"text": "This is my text node."}
```
If your rich text editor can include sensitive customer data, you may want to
scrub or obfuscate that text. To help with that, you can use the Scrubber API.
Here's an example of recursively scrubbing the `'text'` fields of any entity
that gets logged.
```typescript
import { Scrubber } from 'slate'
Scrubber.setScrubber((key, value) => {
if (key === 'text') return '... scrubbed ...'
return value
})
```
By setting the scrubber in this way, the error example given above will be
printed as
```
Cannot get the child of a text node: {"text": "... scrubbed ..."}
```
## Text Randomizer Example
Here's an example "textRandomizer" scrubber, which randomizes particular fields
of Nodes, preserving their length, but replacing their contents with randomly
chosen alphanumeric characters.
```typescript
import { Scrubber } from 'slate'
const textRandomizer = (fieldNames: string[]) => (key, value) => {
if (fieldNames.includes(key)) {
if (typeof value === 'string') {
return value
.split('')
.map(generateRandomCharacter)
.join('')
} else {
return '... scrubbed ...'
}
}
return value
}
const generateRandomCharacter = (): string => {
const chars =
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLKMNOPQRSTUVWXYZ1234567890'
return chars.charAt(Math.floor(Math.random() * chars.length))
}
// randomize the 'text' and 'src' fields of any Node that is included in an
// exception thrown by Slate
Scrubber.setScrubber(Scrubber.textRandomizer(['text', 'src']))
```
In this example, a Node that looked like
```json
{ "text": "My test input string", "count": 5 }
```
will be logged by Slate in an exception as (the random string will differ):
```json
{ "text": "rSIvEzKe39l6rqQSCfyv", "count": 5 }
```

View File

@ -1,5 +1,5 @@
import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react'
import { Editor, Node, Element, Descendant } from 'slate'
import React, { useState, useCallback, useEffect, useRef } from 'react'
import { Editor, Node, Descendant, Scrubber } from 'slate'
import { ReactEditor } from '../plugin/react-editor'
import { FocusedContext } from '../hooks/use-focused'
import { EditorContext } from '../hooks/use-slate-static'
@ -30,12 +30,13 @@ export const Slate = (props: {
if (!Node.isNodeList(value)) {
throw new Error(
`[Slate] value is invalid! Expected a list of elements` +
`but got: ${JSON.stringify(value)}`
`but got: ${Scrubber.stringify(value)}`
)
}
if (!Editor.isEditor(editor)) {
throw new Error(
`[Slate] editor is invalid! you passed:` + `${JSON.stringify(editor)}`
`[Slate] editor is invalid! you passed:` +
`${Scrubber.stringify(editor)}`
)
}
editor.children = value

View File

@ -1,4 +1,13 @@
import { Editor, Node, Path, Point, Range, Transforms, BaseEditor } from 'slate'
import {
BaseEditor,
Editor,
Node,
Path,
Point,
Range,
Scrubber,
Transforms,
} from 'slate'
import { Key } from '../utils/key'
import {
@ -108,7 +117,7 @@ export const ReactEditor = {
}
throw new Error(
`Unable to find the path for Slate node: ${JSON.stringify(node)}`
`Unable to find the path for Slate node: ${Scrubber.stringify(node)}`
)
},
@ -285,7 +294,7 @@ export const ReactEditor = {
if (!domNode) {
throw new Error(
`Cannot resolve a DOM node from Slate node: ${JSON.stringify(node)}`
`Cannot resolve a DOM node from Slate node: ${Scrubber.stringify(node)}`
)
}
@ -337,7 +346,9 @@ export const ReactEditor = {
if (!domPoint) {
throw new Error(
`Cannot resolve a DOM point from Slate point: ${JSON.stringify(point)}`
`Cannot resolve a DOM point from Slate point: ${Scrubber.stringify(
point
)}`
)
}

View File

@ -1,4 +1,5 @@
export * from './create-editor'
export * from './interfaces/custom-types'
export * from './interfaces/editor'
export * from './interfaces/element'
export * from './interfaces/location'
@ -10,6 +11,6 @@ export * from './interfaces/point'
export * from './interfaces/point-ref'
export * from './interfaces/range'
export * from './interfaces/range-ref'
export * from './interfaces/scrubber'
export * from './interfaces/text'
export * from './interfaces/custom-types'
export * from './transforms'

View File

@ -1,5 +1,5 @@
import { produce } from 'immer'
import { Editor, Path, Range, Text } from '..'
import { Editor, Path, Range, Text, Scrubber } from '..'
import { Element, ElementEntry } from './element'
/**
@ -112,7 +112,9 @@ export const Node: NodeInterface = {
if (Text.isText(node)) {
throw new Error(
`Cannot get the ancestor node at path [${path}] because it refers to a text node instead: ${node}`
`Cannot get the ancestor node at path [${path}] because it refers to a text node instead: ${Scrubber.stringify(
node
)}`
)
}
@ -145,7 +147,7 @@ export const Node: NodeInterface = {
child(root: Node, index: number): Descendant {
if (Text.isText(root)) {
throw new Error(
`Cannot get the child of a text node: ${JSON.stringify(root)}`
`Cannot get the child of a text node: ${Scrubber.stringify(root)}`
)
}
@ -153,7 +155,7 @@ export const Node: NodeInterface = {
if (c == null) {
throw new Error(
`Cannot get child at index \`${index}\` in node: ${JSON.stringify(
`Cannot get child at index \`${index}\` in node: ${Scrubber.stringify(
root
)}`
)
@ -203,7 +205,9 @@ export const Node: NodeInterface = {
if (Editor.isEditor(node)) {
throw new Error(
`Cannot get the descendant node at path [${path}] because it refers to the root editor node instead: ${node}`
`Cannot get the descendant node at path [${path}] because it refers to the root editor node instead: ${Scrubber.stringify(
node
)}`
)
}
@ -287,7 +291,7 @@ export const Node: NodeInterface = {
fragment(root: Node, range: Range): Descendant[] {
if (Text.isText(root)) {
throw new Error(
`Cannot get a fragment starting from a root text node: ${JSON.stringify(
`Cannot get a fragment starting from a root text node: ${Scrubber.stringify(
root
)}`
)
@ -339,7 +343,7 @@ export const Node: NodeInterface = {
if (Text.isText(node) || !node.children[p]) {
throw new Error(
`Cannot find a descendant at path [${path}] in node: ${JSON.stringify(
`Cannot find a descendant at path [${path}] in node: ${Scrubber.stringify(
root
)}`
)
@ -428,7 +432,9 @@ export const Node: NodeInterface = {
if (!Text.isText(node)) {
throw new Error(
`Cannot get the leaf node at path [${path}] because it refers to a non-leaf node: ${node}`
`Cannot get the leaf node at path [${path}] because it refers to a non-leaf node: ${Scrubber.stringify(
node
)}`
)
}

View File

@ -0,0 +1,33 @@
export type Scrubber = (key: string, value: unknown) => unknown
export interface ScrubberInterface {
setScrubber(scrubber: Scrubber | undefined): void
stringify(value: any): string
}
let _scrubber: Scrubber | undefined = undefined
/**
* This interface implements a stringify() function, which is used by Slate
* internally when generating exceptions containing end user data. Developers
* using Slate may call Scrubber.setScrubber() to alter the behavior of this
* stringify() function.
*
* For example, to prevent the cleartext logging of 'text' fields within Nodes:
*
* import { Scrubber } from 'slate';
* Scrubber.setScrubber((key, val) => {
* if (key === 'text') return '...scrubbed...'
* return val
* });
*
*/
export const Scrubber: ScrubberInterface = {
setScrubber(scrubber: Scrubber | undefined): void {
_scrubber = scrubber
},
stringify(value: any): string {
return JSON.stringify(value, _scrubber)
},
}

View File

@ -1,17 +1,18 @@
import { createDraft, finishDraft, isDraft } from 'immer'
import {
Node,
Editor,
Selection,
Range,
Point,
Text,
Element,
Operation,
Descendant,
NodeEntry,
Path,
Ancestor,
Descendant,
Editor,
Element,
Node,
NodeEntry,
Operation,
Path,
Point,
Range,
Scrubber,
Selection,
Text,
} from '..'
export interface GeneralTransforms {
@ -73,7 +74,9 @@ const applyToDraft = (editor: Editor, selection: Selection, op: Operation) => {
prev.children.push(...node.children)
} else {
throw new Error(
`Cannot apply a "merge_node" operation at path [${path}] to nodes of different interfaces: ${node} ${prev}`
`Cannot apply a "merge_node" operation at path [${path}] to nodes of different interfaces: ${Scrubber.stringify(
node
)} ${Scrubber.stringify(prev)}`
)
}
@ -236,7 +239,7 @@ const applyToDraft = (editor: Editor, selection: Selection, op: Operation) => {
if (selection == null) {
if (!Range.isRange(newProperties)) {
throw new Error(
`Cannot apply an incomplete "set_selection" operation properties ${JSON.stringify(
`Cannot apply an incomplete "set_selection" operation properties ${Scrubber.stringify(
newProperties
)} when there is no current selection.`
)

View File

@ -1,15 +1,16 @@
import {
Ancestor,
Editor,
Element,
Location,
Node,
NodeEntry,
Path,
Point,
Range,
Scrubber,
Text,
Transforms,
NodeEntry,
Ancestor,
} from '..'
import { NodeMatch, PropsCompare, PropsMerge } from '../interfaces/editor'
import { PointRef } from '../interfaces/point-ref'
@ -406,9 +407,9 @@ export const NodeTransforms: NodeTransforms = {
properties = rest as Partial<Element>
} else {
throw new Error(
`Cannot merge the node at path [${path}] with the previous sibling because it is not the same kind: ${JSON.stringify(
`Cannot merge the node at path [${path}] with the previous sibling because it is not the same kind: ${Scrubber.stringify(
node
)} ${JSON.stringify(prevNode)}`
)} ${Scrubber.stringify(prevNode)}`
)
}

View File

@ -1,4 +1,4 @@
import { Editor, Location, Point, Range, Transforms } from '..'
import { Editor, Location, Point, Range, Scrubber, Transforms } from '..'
import { SelectionEdge, MoveUnit } from '../interfaces/types'
export interface SelectionCollapseOptions {
@ -132,7 +132,7 @@ export const SelectionTransforms: SelectionTransforms = {
if (!Range.isRange(target)) {
throw new Error(
`When setting the selection and the current selection is \`null\` you must provide at least an \`anchor\` and \`focus\`, but you passed: ${JSON.stringify(
`When setting the selection and the current selection is \`null\` you must provide at least an \`anchor\` and \`focus\`, but you passed: ${Scrubber.stringify(
target
)}`
)

View File

@ -0,0 +1,24 @@
import { Node, Scrubber } from 'slate'
export const input = {
customField: 'some very long custom field value that will get scrubbed',
anotherField: 'this field should not get scrambled',
}
export const test = (value: Node) => {
Scrubber.setScrubber((key, value) =>
key === 'customField' ? '... scrubbed ...' : value
)
const stringified = Scrubber.stringify(value)
Scrubber.setScrubber(undefined)
const unmarshaled = JSON.parse(stringified)
return (
// ensure that first field has been scrubbed
unmarshaled.customField === '... scrubbed ...' &&
// ensure that second field is unaltered
unmarshaled.anotherField === input.anotherField
)
}
export const output = true