Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -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.

View File

@@ -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");
});
});

View File

@@ -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);
}

View File

@@ -0,0 +1,5 @@
export {
deserializeEvalSafe,
type EvalSafeOptions,
serializeEvalSafe,
} from "./eval-safe";