@directus/api
Version:
Directus is a real-time API and App dashboard for managing SQL database content
123 lines (121 loc) • 4.97 kB
JavaScript
import { useLogger } from "../../logger/index.js";
import { createDefaultAccountability } from "../../permissions/utils/create-default-accountability.js";
import { getSchema } from "../../utils/get-schema.js";
import { getValidationRules } from "../../services/graphql/rules/get-validation-rules.js";
import "../../services/graphql/rules/index.js";
import { getAddress } from "../../utils/get-address.js";
import { bindPubSub } from "../../services/graphql/subscription.js";
import { handleWebSocketError } from "../errors.js";
import { authenticateConnection } from "../authenticate.js";
import { ConnectionParams } from "../messages.js";
import { getMessageType } from "../utils/message.js";
import SocketController from "./base.js";
import { registerWebSocketEvents } from "./hooks.js";
import { GraphQLService } from "../../services/graphql/index.js";
import "../../services/index.js";
import { useEnv } from "@directus/env";
import { GraphQLError, parse, validate } from "graphql";
import { CloseCode, MessageType, makeServer } from "graphql-ws";
//#region src/websocket/controllers/graphql.ts
const logger = useLogger();
/**
* Handle the `onSubscribe` phase of the GraphQL WebSocket protocol.
* The document is validated against the shared `getValidationRules`, so the WebSocket transport
* enforces the same protections as the HTTP GraphQL endpoint.
*
* @see https://github.com/directus/directus/security/advisories/GHSA-ff8w-8crv-9rcf
*/
async function onSubscribe(ctx, _id, payload) {
const env = useEnv();
let document;
try {
document = parse(payload.query, { maxTokens: Number(env["GRAPHQL_QUERY_TOKEN_LIMIT"]) });
} catch {
return [new GraphQLError("Failed to parse GraphQL document.")];
}
const accountability = ctx.extra.client.accountability;
const schema = await new GraphQLService({
schema: await getSchema(),
scope: "items",
accountability
}).getSchema();
const errors = validate(schema, document, getValidationRules({ operationName: payload.operationName }));
if (errors.length > 0) return errors;
return {
schema,
document,
variableValues: payload.variables ?? void 0,
operationName: payload.operationName ?? void 0
};
}
var GraphQLSubscriptionController = class extends SocketController {
gql;
constructor(httpServer) {
super(httpServer, "WEBSOCKETS_GRAPHQL");
registerWebSocketEvents();
this.server.on("connection", (ws, auth) => {
this.bindEvents(this.createClient(ws, auth));
});
this.gql = makeServer({ onSubscribe });
bindPubSub();
logger.info(`GraphQL Subscriptions started at ${getAddress(httpServer)}${this.endpoint}`);
}
bindEvents(client) {
const closedHandler = this.gql.opened({
protocol: client.protocol,
send: (data) => new Promise((resolve, reject) => {
client.send(data, (err) => err ? reject(err) : resolve());
}),
close: (code, reason) => client.close(code, reason),
onMessage: (cb) => {
client.on("parsed-message", async (message) => {
try {
if (getMessageType(message) === "connection_init" && this.authentication.mode !== "strict") {
const params = ConnectionParams.parse(message["payload"] ?? {});
if (this.authentication.mode === "handshake") if (typeof params.access_token === "string") {
const { accountability, expires_at } = await authenticateConnection({ access_token: params.access_token }, {
ip: client.accountability?.ip ?? null,
userAgent: client.accountability?.userAgent,
origin: client.accountability?.origin
});
client.accountability = accountability;
client.expires_at = expires_at;
} else {
client.close(CloseCode.Forbidden, "Forbidden");
return;
}
} else if (this.authentication.mode === "handshake" && !client.accountability?.user) {
client.close(CloseCode.Forbidden, "Forbidden");
return;
}
await cb(JSON.stringify(message));
} catch (error) {
handleWebSocketError(client, error, MessageType.Error);
}
});
}
}, { client });
client.once("close", (code, reason) => closedHandler(code, reason.toString()));
if (this.authentication.mode === "strict" && !client.accountability?.user) client.close(CloseCode.Forbidden, "Forbidden");
}
setTokenExpireTimer(client) {
if (client.auth_timer !== null) {
clearTimeout(client.auth_timer);
client.auth_timer = null;
}
if (this.authentication.mode !== "handshake") return;
client.auth_timer = setTimeout(() => {
if (!client.accountability?.user) client.close(CloseCode.Forbidden, "Forbidden");
}, this.authentication.timeout);
}
async handleHandshakeUpgrade({ request, socket, head, accountabilityOverrides }) {
this.server.handleUpgrade(request, socket, head, async (ws) => {
this.server.emit("connection", ws, {
accountability: createDefaultAccountability(accountabilityOverrides),
expires_at: null
});
});
}
};
//#endregion
export { GraphQLSubscriptionController, onSubscribe };