UNPKG

@directus/api

Version:

Directus is a real-time API and App dashboard for managing SQL database content

123 lines (121 loc) 4.97 kB
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 };