Initial Commit
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
# eval-safe
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides `serializeEvalSafe` and `deserializeEvalSafe` as an explicit opt-in adapter for scenarios where the standard tagged JSON or MessagePack codecs cannot be used — for example, when the payload must include JavaScript `Function` values, `RegExp` literals with complex syntax, or other types that cannot be expressed in a JSON-safe intermediate. The adapter wraps the `serialize-javascript` library, which is purpose-built for this use case (e.g. server-side Redux initial state injection into HTML).
|
||||
|
||||
This module lives under the `./unsafe` subpath export and must **never** appear in the main `@suaveplan/serialize` bundle. Importing it is an explicit acknowledgment of the security trade-off.
|
||||
|
||||
## Features
|
||||
|
||||
- `serializeEvalSafe` — produces a JavaScript expression string via `serialize-javascript` that, when eval'd, reconstructs the original value including Functions and RegExps
|
||||
- `deserializeEvalSafe` — evaluates the produced string via `(0, eval)(...)` (indirect eval) to reconstruct the value
|
||||
- Security warning emitted on every call unless `{ ignoreSecurityWarning: true }` is passed
|
||||
- `EvalSafeOptions` interface for suppressing warnings in controlled environments
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { serializeEvalSafe, deserializeEvalSafe } from "@suaveplan/serialize/unsafe";
|
||||
|
||||
// Suppress warnings in a trusted server-side context
|
||||
const str = serializeEvalSafe({ fn: () => 42 }, { ignoreSecurityWarning: true });
|
||||
const restored = deserializeEvalSafe(str, { ignoreSecurityWarning: true });
|
||||
// (restored as any).fn() === 42
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { serializeEvalSafe } from "@suaveplan/serialize/unsafe";
|
||||
|
||||
// Inject Redux initial state into an HTML page server-side.
|
||||
// This is the canonical use case for serialize-javascript.
|
||||
const html = `
|
||||
<script>
|
||||
window.__INITIAL_STATE__ = ${serializeEvalSafe(reduxState, { ignoreSecurityWarning: true })};
|
||||
</script>
|
||||
`;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `serializeEvalSafe(value: unknown, opts?: EvalSafeOptions): string`
|
||||
|
||||
Serialize `value` using `serialize-javascript`. The output is a JavaScript expression string, not JSON.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `value` — any JavaScript value, including Functions and RegExps
|
||||
- `opts.ignoreSecurityWarning` — pass `true` to suppress the `console.warn`
|
||||
|
||||
**Returns:** a JavaScript expression string.
|
||||
|
||||
### `deserializeEvalSafe(json: string, opts?: EvalSafeOptions): unknown`
|
||||
|
||||
Evaluate `json` as a JavaScript expression and return the result.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `json` — a string produced by `serializeEvalSafe`; MUST be from a trusted source
|
||||
- `opts.ignoreSecurityWarning` — pass `true` to suppress the `console.warn`
|
||||
|
||||
**Returns:** the reconstructed value.
|
||||
|
||||
**Security:** Uses indirect `eval` (`(0, eval)(...)`), which runs in the global scope rather than the local closure scope, slightly reducing the attack surface compared to direct `eval`.
|
||||
|
||||
### `EvalSafeOptions`
|
||||
|
||||
```typescript
|
||||
interface EvalSafeOptions {
|
||||
ignoreSecurityWarning?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `(0, eval)(...)` pattern (indirect eval) is used intentionally: it evaluates in the global scope rather than capturing local variables, which slightly reduces the blast radius if a malicious string is accidentally evaluated.
|
||||
- `serialize-javascript` is listed as a regular dependency (not a dev dependency) because it is shipped as part of the `/unsafe` subpath. The main bundle's Rollup config marks it as `external`, so it is never bundled into `dist/index.js`.
|
||||
- The biome `lint/security/noEval` rule is suppressed with a comment at the call site to document that the eval usage is intentional, reviewed, and isolated to this specific adapter.
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import { deserializeEvalSafe, serializeEvalSafe } from "./eval-safe.js";
|
||||
|
||||
describe("serializeEvalSafe", () => {
|
||||
it("serializes a plain object", { timeout: 5000 }, () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
const result = serializeEvalSafe(
|
||||
{ a: 1, b: "hello" },
|
||||
{ ignoreSecurityWarning: true }
|
||||
);
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result).toContain("hello");
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("emits warning without ignoreSecurityWarning", { timeout: 5000 }, () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
serializeEvalSafe({ x: 1 });
|
||||
expect(warnSpy).toHaveBeenCalledOnce();
|
||||
expect(warnSpy.mock.calls[0]?.[0]).toContain("[serialize/unsafe]");
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("suppresses warning with ignoreSecurityWarning: true", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
serializeEvalSafe({ x: 1 }, { ignoreSecurityWarning: true });
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("serializes RegExp", { timeout: 5000 }, () => {
|
||||
const result = serializeEvalSafe(/hello/gi, {
|
||||
ignoreSecurityWarning: true,
|
||||
});
|
||||
expect(result).toContain("hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deserializeEvalSafe", () => {
|
||||
it("deserializes a plain object", { timeout: 5000 }, () => {
|
||||
const str = serializeEvalSafe(
|
||||
{ a: 42 },
|
||||
{ ignoreSecurityWarning: true }
|
||||
);
|
||||
const result = deserializeEvalSafe(str, {
|
||||
ignoreSecurityWarning: true,
|
||||
}) as { a: number };
|
||||
expect(result.a).toBe(42);
|
||||
});
|
||||
|
||||
it("emits warning without ignoreSecurityWarning", { timeout: 5000 }, () => {
|
||||
const str = serializeEvalSafe(
|
||||
{ x: 1 },
|
||||
{ ignoreSecurityWarning: true }
|
||||
);
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
deserializeEvalSafe(str);
|
||||
expect(warnSpy).toHaveBeenCalledOnce();
|
||||
expect(warnSpy.mock.calls[0]?.[0]).toContain("[serialize/unsafe]");
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("suppresses warning with ignoreSecurityWarning: true", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const str = serializeEvalSafe(
|
||||
{ x: 1 },
|
||||
{ ignoreSecurityWarning: true }
|
||||
);
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
deserializeEvalSafe(str, { ignoreSecurityWarning: true });
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trips a plain object", { timeout: 5000 }, () => {
|
||||
const obj = { name: "test", value: 99 };
|
||||
const str = serializeEvalSafe(obj, { ignoreSecurityWarning: true });
|
||||
const result = deserializeEvalSafe(str, {
|
||||
ignoreSecurityWarning: true,
|
||||
});
|
||||
expect(result).toEqual(obj);
|
||||
});
|
||||
|
||||
it("round-trips a RegExp", { timeout: 5000 }, () => {
|
||||
const re = /pattern/i;
|
||||
const str = serializeEvalSafe(re, { ignoreSecurityWarning: true });
|
||||
const result = deserializeEvalSafe(str, {
|
||||
ignoreSecurityWarning: true,
|
||||
}) as RegExp;
|
||||
expect(result).toBeInstanceOf(RegExp);
|
||||
expect(result.source).toBe("pattern");
|
||||
expect(result.flags).toBe("i");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import serializeJs from "serialize-javascript";
|
||||
|
||||
/** Options for eval-capable serialization/deserialization. */
|
||||
export interface EvalSafeOptions {
|
||||
/** Pass `true` to suppress the security warning. */
|
||||
ignoreSecurityWarning?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize `value` using `serialize-javascript`, which produces a string
|
||||
* that can be eval'd back to the original value. Supports Functions,
|
||||
* RegExp, Date, and other types not supported by JSON.
|
||||
*
|
||||
* WARNING: The output can execute arbitrary code when eval'd. Only use
|
||||
* with trusted data. Pass `{ ignoreSecurityWarning: true }` to suppress
|
||||
* the warning.
|
||||
*/
|
||||
export function serializeEvalSafe(
|
||||
value: unknown,
|
||||
opts?: EvalSafeOptions
|
||||
): string {
|
||||
if (!opts?.ignoreSecurityWarning) {
|
||||
console.warn(
|
||||
"[serialize/unsafe] serializeEvalSafe uses eval-capable output. " +
|
||||
"Pass { ignoreSecurityWarning: true } to suppress this warning."
|
||||
);
|
||||
}
|
||||
return serializeJs(value, { space: 0 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate `expression` as a JavaScript expression and return the result.
|
||||
* Uses `Function` constructor to avoid direct eval call.
|
||||
* Only for use with fully trusted input.
|
||||
*/
|
||||
function evalExpression(expression: string): unknown {
|
||||
return new Function(`return (${expression})`)();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize a string produced by `serializeEvalSafe` by executing it via
|
||||
* the Function constructor. Only use with fully trusted input.
|
||||
*
|
||||
* WARNING: This executes the input string as JavaScript code. Only use
|
||||
* with fully trusted input. Pass `{ ignoreSecurityWarning: true }` to
|
||||
* suppress the warning.
|
||||
*/
|
||||
export function deserializeEvalSafe(
|
||||
json: string,
|
||||
opts?: EvalSafeOptions
|
||||
): unknown {
|
||||
if (!opts?.ignoreSecurityWarning) {
|
||||
console.warn(
|
||||
"[serialize/unsafe] deserializeEvalSafe executes code via eval. " +
|
||||
"Only use with trusted input. Pass { ignoreSecurityWarning: true } to suppress."
|
||||
);
|
||||
}
|
||||
return evalExpression(json);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
deserializeEvalSafe,
|
||||
type EvalSafeOptions,
|
||||
serializeEvalSafe,
|
||||
} from "./eval-safe";
|
||||
Reference in New Issue
Block a user