mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
72e02a8350
As mentioned in #28609 there's a potential security risk if you allow a passed value to the server to spoof Elements because it allows a hacker to POST cross origin. This is only an issue if your framework allows this which it shouldn't but it seems like we should provide an extra layer of security here. ```js function action(errors, payload) { try { ... } catch (x) { return [newError].concat(errors); } } ``` ```js const [errors, formAction] = useActionState(action); return <div>{errors}</div>; ``` This would allow you to construct a payload where the previous "errors" set includes something like `<script src="danger.js" />`. We could block only elements from being received but it could potentially be a risk with creating other React types like Context too. We use symbols as a way to securely brand these. Most JS don't use this kind of branding with symbols like we do. They're generally properties which we don't support anyway. However in theory someone else could be using them like we do. So in an abundance of carefulness I just ban all symbols from being passed (except by temporary reference) - not just ours. This means that the format isn't fully symmetric even beyond just React Nodes. #28611 allows code that includes symbols/elements to continue working but may have to bail out to replaying instead of no JS sometimes. However, you still can't access the symbols inside the server - they're by reference only.
599 lines
17 KiB
JavaScript
599 lines
17 KiB
JavaScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
* @flow
|
|
*/
|
|
|
|
import type {Thenable} from 'shared/ReactTypes';
|
|
|
|
// The server acts as a Client of itself when resolving Server References.
|
|
// That's why we import the Client configuration from the Server.
|
|
// Everything is aliased as their Server equivalence for clarity.
|
|
import type {
|
|
ServerReferenceId,
|
|
ServerManifest,
|
|
ClientReference as ServerReference,
|
|
} from 'react-client/src/ReactFlightClientConfig';
|
|
|
|
import {
|
|
resolveServerReference,
|
|
preloadModule,
|
|
requireModule,
|
|
} from 'react-client/src/ReactFlightClientConfig';
|
|
|
|
import {createTemporaryReference} from './ReactFlightServerTemporaryReferences';
|
|
|
|
export type JSONValue =
|
|
| number
|
|
| null
|
|
| boolean
|
|
| string
|
|
| {+[key: string]: JSONValue}
|
|
| $ReadOnlyArray<JSONValue>;
|
|
|
|
const PENDING = 'pending';
|
|
const BLOCKED = 'blocked';
|
|
const RESOLVED_MODEL = 'resolved_model';
|
|
const INITIALIZED = 'fulfilled';
|
|
const ERRORED = 'rejected';
|
|
|
|
type PendingChunk<T> = {
|
|
status: 'pending',
|
|
value: null | Array<(T) => mixed>,
|
|
reason: null | Array<(mixed) => mixed>,
|
|
_response: Response,
|
|
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
|
|
};
|
|
type BlockedChunk<T> = {
|
|
status: 'blocked',
|
|
value: null | Array<(T) => mixed>,
|
|
reason: null | Array<(mixed) => mixed>,
|
|
_response: Response,
|
|
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
|
|
};
|
|
type ResolvedModelChunk<T> = {
|
|
status: 'resolved_model',
|
|
value: string,
|
|
reason: null,
|
|
_response: Response,
|
|
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
|
|
};
|
|
type InitializedChunk<T> = {
|
|
status: 'fulfilled',
|
|
value: T,
|
|
reason: null,
|
|
_response: Response,
|
|
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
|
|
};
|
|
type ErroredChunk<T> = {
|
|
status: 'rejected',
|
|
value: null,
|
|
reason: mixed,
|
|
_response: Response,
|
|
then(resolve: (T) => mixed, reject: (mixed) => mixed): void,
|
|
};
|
|
type SomeChunk<T> =
|
|
| PendingChunk<T>
|
|
| BlockedChunk<T>
|
|
| ResolvedModelChunk<T>
|
|
| InitializedChunk<T>
|
|
| ErroredChunk<T>;
|
|
|
|
// $FlowFixMe[missing-this-annot]
|
|
function Chunk(status: any, value: any, reason: any, response: Response) {
|
|
this.status = status;
|
|
this.value = value;
|
|
this.reason = reason;
|
|
this._response = response;
|
|
}
|
|
// We subclass Promise.prototype so that we get other methods like .catch
|
|
Chunk.prototype = (Object.create(Promise.prototype): any);
|
|
// TODO: This doesn't return a new Promise chain unlike the real .then
|
|
Chunk.prototype.then = function <T>(
|
|
this: SomeChunk<T>,
|
|
resolve: (value: T) => mixed,
|
|
reject: (reason: mixed) => mixed,
|
|
) {
|
|
const chunk: SomeChunk<T> = this;
|
|
// If we have resolved content, we try to initialize it first which
|
|
// might put us back into one of the other states.
|
|
switch (chunk.status) {
|
|
case RESOLVED_MODEL:
|
|
initializeModelChunk(chunk);
|
|
break;
|
|
}
|
|
// The status might have changed after initialization.
|
|
switch (chunk.status) {
|
|
case INITIALIZED:
|
|
resolve(chunk.value);
|
|
break;
|
|
case PENDING:
|
|
case BLOCKED:
|
|
if (resolve) {
|
|
if (chunk.value === null) {
|
|
chunk.value = ([]: Array<(T) => mixed>);
|
|
}
|
|
chunk.value.push(resolve);
|
|
}
|
|
if (reject) {
|
|
if (chunk.reason === null) {
|
|
chunk.reason = ([]: Array<(mixed) => mixed>);
|
|
}
|
|
chunk.reason.push(reject);
|
|
}
|
|
break;
|
|
default:
|
|
reject(chunk.reason);
|
|
break;
|
|
}
|
|
};
|
|
|
|
export type Response = {
|
|
_bundlerConfig: ServerManifest,
|
|
_prefix: string,
|
|
_formData: FormData,
|
|
_chunks: Map<number, SomeChunk<any>>,
|
|
_fromJSON: (key: string, value: JSONValue) => any,
|
|
};
|
|
|
|
export function getRoot<T>(response: Response): Thenable<T> {
|
|
const chunk = getChunk(response, 0);
|
|
return (chunk: any);
|
|
}
|
|
|
|
function createPendingChunk<T>(response: Response): PendingChunk<T> {
|
|
// $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
|
|
return new Chunk(PENDING, null, null, response);
|
|
}
|
|
|
|
function wakeChunk<T>(listeners: Array<(T) => mixed>, value: T): void {
|
|
for (let i = 0; i < listeners.length; i++) {
|
|
const listener = listeners[i];
|
|
listener(value);
|
|
}
|
|
}
|
|
|
|
function wakeChunkIfInitialized<T>(
|
|
chunk: SomeChunk<T>,
|
|
resolveListeners: Array<(T) => mixed>,
|
|
rejectListeners: null | Array<(mixed) => mixed>,
|
|
): void {
|
|
switch (chunk.status) {
|
|
case INITIALIZED:
|
|
wakeChunk(resolveListeners, chunk.value);
|
|
break;
|
|
case PENDING:
|
|
case BLOCKED:
|
|
chunk.value = resolveListeners;
|
|
chunk.reason = rejectListeners;
|
|
break;
|
|
case ERRORED:
|
|
if (rejectListeners) {
|
|
wakeChunk(rejectListeners, chunk.reason);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
function triggerErrorOnChunk<T>(chunk: SomeChunk<T>, error: mixed): void {
|
|
if (chunk.status !== PENDING && chunk.status !== BLOCKED) {
|
|
// We already resolved. We didn't expect to see this.
|
|
return;
|
|
}
|
|
const listeners = chunk.reason;
|
|
const erroredChunk: ErroredChunk<T> = (chunk: any);
|
|
erroredChunk.status = ERRORED;
|
|
erroredChunk.reason = error;
|
|
if (listeners !== null) {
|
|
wakeChunk(listeners, error);
|
|
}
|
|
}
|
|
|
|
function createResolvedModelChunk<T>(
|
|
response: Response,
|
|
value: string,
|
|
): ResolvedModelChunk<T> {
|
|
// $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
|
|
return new Chunk(RESOLVED_MODEL, value, null, response);
|
|
}
|
|
|
|
function resolveModelChunk<T>(chunk: SomeChunk<T>, value: string): void {
|
|
if (chunk.status !== PENDING) {
|
|
// We already resolved. We didn't expect to see this.
|
|
return;
|
|
}
|
|
const resolveListeners = chunk.value;
|
|
const rejectListeners = chunk.reason;
|
|
const resolvedChunk: ResolvedModelChunk<T> = (chunk: any);
|
|
resolvedChunk.status = RESOLVED_MODEL;
|
|
resolvedChunk.value = value;
|
|
if (resolveListeners !== null) {
|
|
// This is unfortunate that we're reading this eagerly if
|
|
// we already have listeners attached since they might no
|
|
// longer be rendered or might not be the highest pri.
|
|
initializeModelChunk(resolvedChunk);
|
|
// The status might have changed after initialization.
|
|
wakeChunkIfInitialized(chunk, resolveListeners, rejectListeners);
|
|
}
|
|
}
|
|
|
|
function bindArgs(fn: any, args: any) {
|
|
return fn.bind.apply(fn, [null].concat(args));
|
|
}
|
|
|
|
function loadServerReference<T>(
|
|
response: Response,
|
|
id: ServerReferenceId,
|
|
bound: null | Thenable<Array<any>>,
|
|
parentChunk: SomeChunk<T>,
|
|
parentObject: Object,
|
|
key: string,
|
|
): T {
|
|
const serverReference: ServerReference<T> =
|
|
resolveServerReference<$FlowFixMe>(response._bundlerConfig, id);
|
|
// We expect most servers to not really need this because you'd just have all
|
|
// the relevant modules already loaded but it allows for lazy loading of code
|
|
// if needed.
|
|
const preloadPromise = preloadModule(serverReference);
|
|
let promise: Promise<T>;
|
|
if (bound) {
|
|
promise = Promise.all([(bound: any), preloadPromise]).then(
|
|
([args]: Array<any>) => bindArgs(requireModule(serverReference), args),
|
|
);
|
|
} else {
|
|
if (preloadPromise) {
|
|
promise = Promise.resolve(preloadPromise).then(() =>
|
|
requireModule(serverReference),
|
|
);
|
|
} else {
|
|
// Synchronously available
|
|
return requireModule(serverReference);
|
|
}
|
|
}
|
|
promise.then(
|
|
createModelResolver(parentChunk, parentObject, key),
|
|
createModelReject(parentChunk),
|
|
);
|
|
// We need a placeholder value that will be replaced later.
|
|
return (null: any);
|
|
}
|
|
|
|
let initializingChunk: ResolvedModelChunk<any> = (null: any);
|
|
let initializingChunkBlockedModel: null | {deps: number, value: any} = null;
|
|
function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
|
|
const prevChunk = initializingChunk;
|
|
const prevBlocked = initializingChunkBlockedModel;
|
|
initializingChunk = chunk;
|
|
initializingChunkBlockedModel = null;
|
|
try {
|
|
const value: T = JSON.parse(chunk.value, chunk._response._fromJSON);
|
|
if (
|
|
initializingChunkBlockedModel !== null &&
|
|
initializingChunkBlockedModel.deps > 0
|
|
) {
|
|
initializingChunkBlockedModel.value = value;
|
|
// We discovered new dependencies on modules that are not yet resolved.
|
|
// We have to go the BLOCKED state until they're resolved.
|
|
const blockedChunk: BlockedChunk<T> = (chunk: any);
|
|
blockedChunk.status = BLOCKED;
|
|
blockedChunk.value = null;
|
|
blockedChunk.reason = null;
|
|
} else {
|
|
const initializedChunk: InitializedChunk<T> = (chunk: any);
|
|
initializedChunk.status = INITIALIZED;
|
|
initializedChunk.value = value;
|
|
}
|
|
} catch (error) {
|
|
const erroredChunk: ErroredChunk<T> = (chunk: any);
|
|
erroredChunk.status = ERRORED;
|
|
erroredChunk.reason = error;
|
|
} finally {
|
|
initializingChunk = prevChunk;
|
|
initializingChunkBlockedModel = prevBlocked;
|
|
}
|
|
}
|
|
|
|
// Report that any missing chunks in the model is now going to throw this
|
|
// error upon read. Also notify any pending promises.
|
|
export function reportGlobalError(response: Response, error: Error): void {
|
|
response._chunks.forEach(chunk => {
|
|
// If this chunk was already resolved or errored, it won't
|
|
// trigger an error but if it wasn't then we need to
|
|
// because we won't be getting any new data to resolve it.
|
|
if (chunk.status === PENDING) {
|
|
triggerErrorOnChunk(chunk, error);
|
|
}
|
|
});
|
|
}
|
|
|
|
function getChunk(response: Response, id: number): SomeChunk<any> {
|
|
const chunks = response._chunks;
|
|
let chunk = chunks.get(id);
|
|
if (!chunk) {
|
|
const prefix = response._prefix;
|
|
const key = prefix + id;
|
|
// Check if we have this field in the backing store already.
|
|
const backingEntry = response._formData.get(key);
|
|
if (backingEntry != null) {
|
|
// We assume that this is a string entry for now.
|
|
chunk = createResolvedModelChunk(response, (backingEntry: any));
|
|
} else {
|
|
// We're still waiting on this entry to stream in.
|
|
chunk = createPendingChunk(response);
|
|
}
|
|
chunks.set(id, chunk);
|
|
}
|
|
return chunk;
|
|
}
|
|
|
|
function createModelResolver<T>(
|
|
chunk: SomeChunk<T>,
|
|
parentObject: Object,
|
|
key: string,
|
|
): (value: any) => void {
|
|
let blocked;
|
|
if (initializingChunkBlockedModel) {
|
|
blocked = initializingChunkBlockedModel;
|
|
blocked.deps++;
|
|
} else {
|
|
blocked = initializingChunkBlockedModel = {
|
|
deps: 1,
|
|
value: null,
|
|
};
|
|
}
|
|
return value => {
|
|
parentObject[key] = value;
|
|
blocked.deps--;
|
|
if (blocked.deps === 0) {
|
|
if (chunk.status !== BLOCKED) {
|
|
return;
|
|
}
|
|
const resolveListeners = chunk.value;
|
|
const initializedChunk: InitializedChunk<T> = (chunk: any);
|
|
initializedChunk.status = INITIALIZED;
|
|
initializedChunk.value = blocked.value;
|
|
if (resolveListeners !== null) {
|
|
wakeChunk(resolveListeners, blocked.value);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
function createModelReject<T>(chunk: SomeChunk<T>): (error: mixed) => void {
|
|
return (error: mixed) => triggerErrorOnChunk(chunk, error);
|
|
}
|
|
|
|
function getOutlinedModel(response: Response, id: number): any {
|
|
const chunk = getChunk(response, id);
|
|
if (chunk.status === RESOLVED_MODEL) {
|
|
initializeModelChunk(chunk);
|
|
}
|
|
if (chunk.status !== INITIALIZED) {
|
|
// We know that this is emitted earlier so otherwise it's an error.
|
|
throw chunk.reason;
|
|
}
|
|
return chunk.value;
|
|
}
|
|
|
|
function parseModelString(
|
|
response: Response,
|
|
parentObject: Object,
|
|
key: string,
|
|
value: string,
|
|
): any {
|
|
if (value[0] === '$') {
|
|
switch (value[1]) {
|
|
case '$': {
|
|
// This was an escaped string value.
|
|
return value.slice(1);
|
|
}
|
|
case '@': {
|
|
// Promise
|
|
const id = parseInt(value.slice(2), 16);
|
|
const chunk = getChunk(response, id);
|
|
return chunk;
|
|
}
|
|
case 'F': {
|
|
// Server Reference
|
|
const id = parseInt(value.slice(2), 16);
|
|
// TODO: Just encode this in the reference inline instead of as a model.
|
|
const metaData: {id: ServerReferenceId, bound: Thenable<Array<any>>} =
|
|
getOutlinedModel(response, id);
|
|
return loadServerReference(
|
|
response,
|
|
metaData.id,
|
|
metaData.bound,
|
|
initializingChunk,
|
|
parentObject,
|
|
key,
|
|
);
|
|
}
|
|
case 'T': {
|
|
// Temporary Reference
|
|
return createTemporaryReference(value.slice(2));
|
|
}
|
|
case 'Q': {
|
|
// Map
|
|
const id = parseInt(value.slice(2), 16);
|
|
const data = getOutlinedModel(response, id);
|
|
return new Map(data);
|
|
}
|
|
case 'W': {
|
|
// Set
|
|
const id = parseInt(value.slice(2), 16);
|
|
const data = getOutlinedModel(response, id);
|
|
return new Set(data);
|
|
}
|
|
case 'K': {
|
|
// FormData
|
|
const stringId = value.slice(2);
|
|
const formPrefix = response._prefix + stringId + '_';
|
|
const data = new FormData();
|
|
const backingFormData = response._formData;
|
|
// We assume that the reference to FormData always comes after each
|
|
// entry that it references so we can assume they all exist in the
|
|
// backing store already.
|
|
// $FlowFixMe[prop-missing] FormData has forEach on it.
|
|
backingFormData.forEach((entry: File | string, entryKey: string) => {
|
|
if (entryKey.startsWith(formPrefix)) {
|
|
data.append(entryKey.slice(formPrefix.length), entry);
|
|
}
|
|
});
|
|
return data;
|
|
}
|
|
case 'I': {
|
|
// $Infinity
|
|
return Infinity;
|
|
}
|
|
case '-': {
|
|
// $-0 or $-Infinity
|
|
if (value === '$-0') {
|
|
return -0;
|
|
} else {
|
|
return -Infinity;
|
|
}
|
|
}
|
|
case 'N': {
|
|
// $NaN
|
|
return NaN;
|
|
}
|
|
case 'u': {
|
|
// matches "$undefined"
|
|
// Special encoding for `undefined` which can't be serialized as JSON otherwise.
|
|
return undefined;
|
|
}
|
|
case 'D': {
|
|
// Date
|
|
return new Date(Date.parse(value.slice(2)));
|
|
}
|
|
case 'n': {
|
|
// BigInt
|
|
return BigInt(value.slice(2));
|
|
}
|
|
default: {
|
|
// We assume that anything else is a reference ID.
|
|
const id = parseInt(value.slice(1), 16);
|
|
const chunk = getChunk(response, id);
|
|
switch (chunk.status) {
|
|
case RESOLVED_MODEL:
|
|
initializeModelChunk(chunk);
|
|
break;
|
|
}
|
|
// The status might have changed after initialization.
|
|
switch (chunk.status) {
|
|
case INITIALIZED:
|
|
return chunk.value;
|
|
case PENDING:
|
|
case BLOCKED:
|
|
const parentChunk = initializingChunk;
|
|
chunk.then(
|
|
createModelResolver(parentChunk, parentObject, key),
|
|
createModelReject(parentChunk),
|
|
);
|
|
return null;
|
|
default:
|
|
throw chunk.reason;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function createResponse(
|
|
bundlerConfig: ServerManifest,
|
|
formFieldPrefix: string,
|
|
backingFormData?: FormData = new FormData(),
|
|
): Response {
|
|
const chunks: Map<number, SomeChunk<any>> = new Map();
|
|
const response: Response = {
|
|
_bundlerConfig: bundlerConfig,
|
|
_prefix: formFieldPrefix,
|
|
_formData: backingFormData,
|
|
_chunks: chunks,
|
|
_fromJSON: function (this: any, key: string, value: JSONValue) {
|
|
if (typeof value === 'string') {
|
|
// We can't use .bind here because we need the "this" value.
|
|
return parseModelString(response, this, key, value);
|
|
}
|
|
return value;
|
|
},
|
|
};
|
|
return response;
|
|
}
|
|
|
|
export function resolveField(
|
|
response: Response,
|
|
key: string,
|
|
value: string,
|
|
): void {
|
|
// Add this field to the backing store.
|
|
response._formData.append(key, value);
|
|
const prefix = response._prefix;
|
|
if (key.startsWith(prefix)) {
|
|
const chunks = response._chunks;
|
|
const id = +key.slice(prefix.length);
|
|
const chunk = chunks.get(id);
|
|
if (chunk) {
|
|
// We were waiting on this key so now we can resolve it.
|
|
resolveModelChunk(chunk, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function resolveFile(response: Response, key: string, file: File): void {
|
|
// Add this field to the backing store.
|
|
response._formData.append(key, file);
|
|
}
|
|
|
|
export opaque type FileHandle = {
|
|
chunks: Array<Uint8Array>,
|
|
filename: string,
|
|
mime: string,
|
|
};
|
|
|
|
export function resolveFileInfo(
|
|
response: Response,
|
|
key: string,
|
|
filename: string,
|
|
mime: string,
|
|
): FileHandle {
|
|
return {
|
|
chunks: [],
|
|
filename,
|
|
mime,
|
|
};
|
|
}
|
|
|
|
export function resolveFileChunk(
|
|
response: Response,
|
|
handle: FileHandle,
|
|
chunk: Uint8Array,
|
|
): void {
|
|
handle.chunks.push(chunk);
|
|
}
|
|
|
|
export function resolveFileComplete(
|
|
response: Response,
|
|
key: string,
|
|
handle: FileHandle,
|
|
): void {
|
|
// Add this file to the backing store.
|
|
// Node.js doesn't expose a global File constructor so we need to use
|
|
// the append() form that takes the file name as the third argument,
|
|
// to create a File object.
|
|
const blob = new Blob(handle.chunks, {type: handle.mime});
|
|
response._formData.append(key, blob, handle.filename);
|
|
}
|
|
|
|
export function close(response: Response): void {
|
|
// In case there are any remaining unresolved chunks, they won't
|
|
// be resolved now. So we need to issue an error to those.
|
|
// Ideally we should be able to early bail out if we kept a
|
|
// ref count of pending chunks.
|
|
reportGlobalError(response, new Error('Connection closed.'));
|
|
}
|