drupal-mcp-connector 1.8.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,12 @@
1
1
  ---
2
- description: "Update many entities of a single type + bundle in one call. Permission is checked once; each item is updated independently, so the batch continues past individual failures (partial success). Each item requires an 'id' (UUID); items missing an id are reported as per-item failures. Returns per-item { index, success, id | error } and a summary { updated, failed }."
2
+ description: "Update many entities of a single type + bundle in one call. Permission is checked once; each item is updated independently, so the batch continues past individual failures (partial success). Each item requires an 'id' (UUID); items missing an id are reported as per-item failures. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision) so bulk edits do not mutate live default revisions. Returns per-item { index, success, id | error } and a summary { updated, failed }."
3
3
  argument-hint: "<entityType> <bundle> <items> [site]"
4
4
  allowed-tools: mcp__drupal__drupal_bulk_update
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_bulk_update` MCP tool.
8
8
 
9
- Update many entities of a single type + bundle in one call. Permission is checked once; each item is updated independently, so the batch continues past individual failures (partial success). Each item requires an 'id' (UUID); items missing an id are reported as per-item failures. Returns per-item { index, success, id | error } and a summary { updated, failed }.
9
+ Update many entities of a single type + bundle in one call. Permission is checked once; each item is updated independently, so the batch continues past individual failures (partial success). Each item requires an 'id' (UUID); items missing an id are reported as per-item failures. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision) so bulk edits do not mutate live default revisions. Returns per-item { index, success, id | error } and a summary { updated, failed }.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -16,7 +16,7 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
16
16
 
17
17
  **Optional:**
18
18
  - `site` (string): omit for the default site
19
- - `status` (boolean (true/false))
19
+ - `status` (boolean (true/false)): Published flag. Defaults to false (unpublished). Requires allowPublish when true.
20
20
  - `fields` (object (pass as JSON)): Additional field values — include the source field (e.g. field_media_oembed_video: 'https://youtu.be/...')
21
21
 
22
22
  If a required parameter is missing from `$ARGUMENTS`, ask before calling — do not invent values. Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the single tool call and summarize the result.
@@ -1,12 +1,12 @@
1
1
  ---
2
- description: "Run a read-only SQL query (SELECT, SHOW, DESCRIBE, EXPLAIN only) via Drush. Write queries are blocked by the security layer."
2
+ description: "Run a single read-only SELECT through mcp_sentinel's governed command (`drush mcp-sentinel:sql-query`). Requires the site to set drushSsh.rawSql=\"governed\" AND the site's policy profile to set allow_raw_sql; both are off by default. The server refuses statements touching a denied entity type, a non-entity table, or a redacted field, and records every attempt in the tamper-evident audit log. Use the site-context or entity-schema tools for schema introspection."
3
3
  argument-hint: "<query> [site]"
4
4
  allowed-tools: mcp__drupal__drupal_drush_sql_query
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_drush_sql_query` MCP tool.
8
8
 
9
- Run a read-only SQL query (SELECT, SHOW, DESCRIBE, EXPLAIN only) via Drush. Write queries are blocked by the security layer.
9
+ Run a single read-only SELECT through mcp_sentinel's governed command (`drush mcp-sentinel:sql-query`). Requires the site to set drushSsh.rawSql="governed" AND the site's policy profile to set allow_raw_sql; both are off by default. The server refuses statements touching a denied entity type, a non-entity table, or a redacted field, and records every attempt in the tamper-evident audit log. Use the site-context or entity-schema tools for schema introspection.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -1,12 +1,12 @@
1
1
  ---
2
- description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change."
2
+ description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision)."
3
3
  argument-hint: "<entityType> <bundle> <id> [site] [attributes] [relationships] [dryRun] [returning]"
4
4
  allowed-tools: mcp__drupal__drupal_entity_update
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_entity_update` MCP tool.
8
8
 
9
- Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change.
9
+ Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision).
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -1,12 +1,12 @@
1
1
  ---
2
- description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. Entity-reference fields go in `relationships`, not `fields`."
2
+ description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Entity-reference fields go in `relationships`, not `fields`."
3
3
  argument-hint: "<type> <id> [site] [title] [body] [summary] [status] [moderationState] [fields] [relationships] [dryRun] [returning]"
4
4
  allowed-tools: mcp__drupal__drupal_update_node
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_update_node` MCP tool.
8
8
 
9
- Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. Entity-reference fields go in `relationships`, not `fields`.
9
+ Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Entity-reference fields go in `relationships`, not `fields`.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -20,7 +20,7 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
20
20
  - `body` (string)
21
21
  - `summary` (string)
22
22
  - `status` (boolean (true/false)): Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set.
23
- - `moderationState` (string): Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status.
23
+ - `moderationState` (string): Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status. Required to keep or re-publish a live node — omitting it on a published moderated node defaults the write to 'draft'.
24
24
  - `fields` (object (pass as JSON)): Scalar/attribute field values keyed by machine name. Entity-reference fields go in `relationships`, not here.
25
25
  - `relationships` (object (pass as JSON)): Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }.
26
26
  - `dryRun` (boolean (true/false)): Validate and return a preview of the update without committing.
@@ -1,17 +1,17 @@
1
1
  ---
2
- description: "Convenience tool: upload a local file and immediately create a Media entity in one step. Best for the common 'add an image' workflow."
2
+ description: "Convenience tool: upload a local file and immediately create a Media entity in one step. Best for the common 'add an image' workflow. Media defaults to unpublished."
3
3
  argument-hint: "<filePath> <mediaType> <fieldName> [site] [mediaName] [altText] [status]"
4
4
  allowed-tools: mcp__drupal__drupal_upload_file_and_create_media
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_upload_file_and_create_media` MCP tool.
8
8
 
9
- Convenience tool: upload a local file and immediately create a Media entity in one step. Best for the common 'add an image' workflow.
9
+ Convenience tool: upload a local file and immediately create a Media entity in one step. Best for the common 'add an image' workflow. Media defaults to unpublished.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
13
13
  **Required:**
14
- - `filePath` (string): Absolute local path to the file
14
+ - `filePath` (string): Local path to the file (must resolve under MCP_UPLOAD_ROOT or the connector working directory)
15
15
  - `mediaType` (string): Media type machine name, e.g. 'image'
16
16
  - `fieldName` (string): Source field machine name, e.g. 'field_media_image'
17
17
 
@@ -19,6 +19,6 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
19
19
  - `site` (string): omit for the default site
20
20
  - `mediaName` (string): Name for the media entity (defaults to filename)
21
21
  - `altText` (string): Alt text for image media
22
- - `status` (boolean (true/false))
22
+ - `status` (boolean (true/false)): Published flag. Defaults to false. Requires allowPublish when true.
23
23
 
24
24
  If a required parameter is missing from `$ARGUMENTS`, ask before calling — do not invent values. Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the single tool call and summarize the result.
@@ -11,7 +11,7 @@ Upload a local file to Drupal and create a File entity. Returns the file UUID to
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
13
13
  **Required:**
14
- - `filePath` (string): Absolute local path to the file to upload
14
+ - `filePath` (string): Local path to the file (must resolve under MCP_UPLOAD_ROOT or the connector working directory)
15
15
  - `bundle` (string): Bundle machine name, e.g. 'image', 'article'
16
16
  - `fieldName` (string): Field machine name, e.g. 'field_media_image', 'field_image'
17
17
 
package/CHANGELOG.md CHANGED
@@ -7,6 +7,100 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.1.0] - 2026-07-31
11
+
12
+ ### Security
13
+ - **Specialized tools honor entity allowlists/denylists (#138).**
14
+ `drupal_*_node`, media, and taxonomy tools now call the same
15
+ `assertReadAllowed` / `assertWriteAllowed` / `assertDeleteAllowed` gates as
16
+ `drupal_entity_*`, so a denied type (e.g. `media`) cannot be reached by
17
+ switching tool names.
18
+ - **Upload path allowlist and path segment hardening (#137).** File uploads
19
+ must resolve under `MCP_UPLOAD_ROOT` (or the connector cwd by default);
20
+ entity/bundle/field path segments are machine-name validated and encoded;
21
+ Content-Disposition filenames are sanitized. Sensitive paths (`.env*`,
22
+ `.ssh`, connector `config.json`) are refused even under an allowed root.
23
+ - **Media no longer publishes by default (#139).** `drupal_create_media` and
24
+ upload-and-create default to `status: false` and honor `assertPublishAllowed`.
25
+ `moderation_state: published` and `drupal_set_moderation_state` to published
26
+ are treated as publish-bearing.
27
+ - **HTTPS fails closed without auth when non-loopback (#141).** Binding beyond
28
+ loopback requires `MCP_AUTH_TOKEN` unless `MCP_ALLOW_UNAUTHENTICATED=1`.
29
+ Non-loopback HTTPS defaults to 120 req/min rate limiting when
30
+ `MCP_RATE_LIMIT` is unset.
31
+ - **Link-checker does not follow redirects (#143).** Live checks use
32
+ `redirect: "manual"` to avoid SSRF via 302 to private/metadata targets.
33
+ - **auditor / production-strict apply SENSITIVE_DENY** (partial #140). Secrets,
34
+ governance, and account entity types stay denied on those presets. Default
35
+ when `security` is omitted remains `development` (tracked in #140).
36
+
37
+ ### Fixed
38
+ - **Published moderated updates default to a draft forward revision (#131).**
39
+ `drupal_update_node`, `drupal_bulk_update`, and `drupal_entity_update` now
40
+ sniff the target entity: when it is published under content_moderation and
41
+ the caller omitted `moderation_state` / `moderationState`, the write is sent
42
+ as `moderation_state: draft` instead of omitting the field. That keeps bulk
43
+ relationship-wiring and field edits as reviewable forward revisions rather
44
+ than live default-revision mutations if a server-side publish gate
45
+ mis-classifies the write. Callers that want a same-state save must pass an
46
+ explicit moderation state.
47
+
48
+ ### Added
49
+ - **Provider-agnostic agent instructions.** Root `AGENTS.md` is the only
50
+ committed agent rule file (any coding agent). No vendor-specific
51
+ `.grok/` / `CLAUDE.md` / parallel rule trees. Generated slash stubs under
52
+ `.claude/commands/` remain client ergonomics for MCP operators, not project
53
+ development rules.
54
+
55
+ ### Documentation
56
+ - Security, hardening, deployment, tools-reference, threat-model, and
57
+ architecture docs updated for the 2.1 controls (fail-closed HTTPS, upload
58
+ roots, publish/draft defaults, GraphQL policy caveat).
59
+
60
+ ## [2.0.0] - 2026-07-29
61
+
62
+ ### Fixed
63
+ - **Re-pushing a release tag no longer fails the publish job.** The workflow
64
+ now skips publishing a version already on the registry. Moving or recreating
65
+ a tag is a normal recovery operation, and npm's E403 "cannot publish over the
66
+ previously published version" reads as a broken release when the registry is
67
+ in exactly the state you wanted.
68
+
69
+ ### Changed
70
+ - **BREAKING: `drupal_drush_sql_query` no longer runs ungoverned SQL, and is off
71
+ by default.** It called `drush sql:query`, which executes below Drupal's
72
+ entity API — so a site's `mcp_sentinel` policy profile, its denied entity
73
+ types, its redacted fields and its audit log had no effect on anything this
74
+ tool read. A statement could return exactly the data the same site refused
75
+ over JSON:API, and nothing recorded that it had. That is not fixable on the
76
+ Drupal side: Drush caps `sql:query`'s bootstrap below the level at which
77
+ module command files are discovered, so no module hook can run on its path.
78
+ Nor is it fixable here — this process holds the SSH key, so a client-side
79
+ check is a promise made by the thing being constrained.
80
+
81
+ The tool now calls `drush mcp-sentinel:sql-query` (mcp_sentinel ≥ 1.14),
82
+ where Drupal is fully bootstrapped and the policy profile decides. Two
83
+ independent opt-ins are required, both off by default: `drushSsh.rawSql:
84
+ "governed"` on the site here, and `allow_raw_sql` on the policy profile
85
+ there. There is no ungoverned mode — keeping one behind a flag would have
86
+ left the bypass a config key away and still invisible when used.
87
+
88
+ **To migrate:** set both flags, add `mcp-sentinel:sql-query` to
89
+ `allowedCommands` if the site pins that list, and expect a narrower tool —
90
+ the server accepts a single `SELECT` over entity tables only (no `SHOW` /
91
+ `DESCRIBE` / `EXPLAIN`, no expressions, no `SELECT *` on a table carrying a
92
+ redacted column). Schema introspection moves to the site-context and
93
+ entity-schema tools. Sites that do not run mcp_sentinel lose this tool; raw
94
+ database access belongs to the operator's own shell, not to an agent.
95
+
96
+ ### Fixed
97
+ - **Docs: redirect entities are publishable.** The `redirects.js` header claimed
98
+ redirect entities have no enabled/disabled flag. That has been stale since the
99
+ redirect module's dev-1.x made the entity publishable (`enabled` is the published
100
+ key). Corrected the doc and added a verify-after-create caveat for sites on older
101
+ `mcp_sentinel` releases (≤ 1.9), where agent-created redirects can arrive silently
102
+ disabled until the publish gate exempts redirects.
103
+
10
104
  ## [1.8.1] - 2026-07-23
11
105
 
12
106
  ### Security
package/README.md CHANGED
@@ -209,6 +209,37 @@ drush mcp-sentinel:setup
209
209
 
210
210
  Governance keys off the authenticated account's role and OAuth scopes — not request headers. The connector sends an `X-MCP-Client` identity header purely as a log label. See the [MCP Sentinel project page](https://www.drupal.org/project/mcp_sentinel) for the full contract.
211
211
 
212
+ ### Raw SQL is governed, or it is off
213
+
214
+ The Drush bridge runs over SSH, which is *below* everything above: a `drush`
215
+ subprocess does not make a request Drupal can govern, and most Drush commands
216
+ never load Drupal's module system at all. `drupal_drush_sql_query` used to
217
+ exploit that without meaning to — it called `drush sql:query`, so no policy
218
+ profile, no denied entity type, no field redaction and no audit entry applied
219
+ to anything it read.
220
+
221
+ It now calls `drush mcp-sentinel:sql-query` (mcp_sentinel ≥ 1.14), which runs
222
+ with Drupal fully bootstrapped and enforces the same profile that governs
223
+ JSON:API. **Two opt-ins, both off by default**, are required:
224
+
225
+ ```jsonc
226
+ // this connector, per site
227
+ "drushSsh": {
228
+ "rawSql": "governed", // no other value enables it
229
+ "allowedCommands": ["mcp-sentinel:sql-query"] // only if you pin this list
230
+ }
231
+ ```
232
+
233
+ …plus `allow_raw_sql` on the policy profile in Drupal. Expect a much narrower
234
+ tool than before: one `SELECT`, entity tables only, no expressions, and no
235
+ `SELECT *` on a table carrying a redacted column. Every attempt — permitted or
236
+ refused — lands in the audit chain with its statement text.
237
+
238
+ The rest of the bridge (`sql:cli`, `sql:dump`, `php:eval` and anything else you
239
+ reach over SSH) is **outside Drupal governance by construction**. Treat SSH as
240
+ an operator channel: keep the agent's credentials off it, and pin
241
+ `allowedCommands` per site.
242
+
212
243
  ---
213
244
 
214
245
  ## Documentation
@@ -70,7 +70,7 @@
70
70
  },
71
71
  "serverTools": { "url": "/mcp" },
72
72
  "drushSsh": {
73
- "_comment": "Dev only. DDEV web-container SSH target. Whitelisted to config export/status — every other drupal_drush_* tool is blocked here.",
73
+ "_comment": "Dev only. DDEV web-container SSH target. Whitelisted to config export/status — every other drupal_drush_* tool is blocked here. rawSql is omitted, so drupal_drush_sql_query is refused: raw SQL reads underneath Drupal's entity API and is only available via mcp_sentinel's governed command. To enable it, set rawSql:\"governed\", add \"mcp-sentinel:sql-query\" to allowedCommands, and set allow_raw_sql on the site's policy profile.",
74
74
  "host": "<ddev-web-container-ssh-host>",
75
75
  "user": "<ddev-ssh-user>",
76
76
  "keyPath": "~/.ssh/id_ed25519",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "1.8.1",
3
+ "version": "2.1.0",
4
4
  "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -8,6 +8,10 @@
8
8
  * `mcp__drupal__<tool>` and instructs the model to parse `$ARGUMENTS` into the
9
9
  * tool's parameters before making a single call.
10
10
  *
11
+ * These files are *client ergonomics* for operators using Claude Code as an MCP
12
+ * client against this server — not provider-specific project rules. Development
13
+ * instructions for any agent live only in AGENTS.md.
14
+ *
11
15
  * Driven from the same tool definitions as the server (src/tools/index.js), so the
12
16
  * command set never drifts from the tools. Run: `npm run generate:commands`.
13
17
  *
package/src/index.js CHANGED
@@ -384,6 +384,7 @@ if (transport === "stdio") {
384
384
 
385
385
  const authToken = process.env.MCP_AUTH_TOKEN || "";
386
386
  const checkAuth = makeBearerCheck(authToken);
387
+ const allowUnauth = process.env.MCP_ALLOW_UNAUTHENTICATED === "1";
387
388
  if (!authToken) {
388
389
  console.error(
389
390
  "[drupal-mcp-connector] WARNING: the /mcp endpoint is UNAUTHENTICATED. " +
@@ -450,11 +451,33 @@ if (transport === "stdio") {
450
451
  return mcpTransport;
451
452
  }
452
453
 
453
- // Optional fixed-window rate limiting on /mcp, keyed by client IP. Off unless
454
- // MCP_RATE_LIMIT > 0. Counts are per-process; for multi-replica deployments
455
- // prefer rate limiting at the reverse proxy.
456
- const rateLimit = Number(process.env.MCP_RATE_LIMIT || 0);
454
+ const hasTls = Boolean(tlsCfg.certPath && tlsCfg.keyPath);
455
+ // Unauthenticated plain HTTP must never bind beyond loopback. A non-loopback
456
+ // bind is allowed only alongside TLS, via an explicit MCP_BIND_HOST opt-in.
457
+ const bindHost = hasTls ? (process.env.MCP_BIND_HOST || "0.0.0.0") : "127.0.0.1";
458
+ const isLoopbackBind = bindHost === "127.0.0.1" || bindHost === "::1" || bindHost === "localhost";
459
+
460
+ // #141: fail closed when HTTPS is network-facing without a bearer token.
461
+ // Loopback binds and explicit MCP_ALLOW_UNAUTHENTICATED=1 remain for local/proxy setups.
462
+ if (!authToken && !isLoopbackBind && !allowUnauth) {
463
+ console.error(
464
+ "[drupal-mcp-connector] FATAL: MCP_AUTH_TOKEN is required when binding beyond loopback.\n" +
465
+ " Set MCP_AUTH_TOKEN, bind to 127.0.0.1 (default without MCP_BIND_HOST), or set\n" +
466
+ " MCP_ALLOW_UNAUTHENTICATED=1 only behind a trusted auth boundary."
467
+ );
468
+ process.exit(1);
469
+ }
470
+
471
+ // Optional fixed-window rate limiting on /mcp, keyed by client IP.
472
+ // HTTPS non-loopback defaults to 120 req/min when MCP_RATE_LIMIT is unset (#141).
473
+ // Set MCP_RATE_LIMIT=0 to disable. Counts are per-process; multi-replica should
474
+ // also rate-limit at the reverse proxy.
457
475
  const rateWindowSec = Number(process.env.MCP_RATE_WINDOW_SEC || 60);
476
+ const rateLimitEnv = process.env.MCP_RATE_LIMIT;
477
+ const rateLimitDefault = (hasTls && !isLoopbackBind) ? 120 : 0;
478
+ const rateLimit = rateLimitEnv === undefined || rateLimitEnv === ""
479
+ ? rateLimitDefault
480
+ : Number(rateLimitEnv);
458
481
  const rateLimiter = rateLimit > 0
459
482
  ? createRateLimiter({ limit: rateLimit, windowMs: rateWindowSec * 1000 })
460
483
  : null;
@@ -474,11 +497,6 @@ if (transport === "stdio") {
474
497
 
475
498
  const nodeServer = createNodeServer(requestHandler);
476
499
 
477
- const hasTls = Boolean(tlsCfg.certPath && tlsCfg.keyPath);
478
- // Unauthenticated plain HTTP must never bind beyond loopback. A non-loopback
479
- // bind is allowed only alongside TLS, via an explicit MCP_BIND_HOST opt-in.
480
- const bindHost = hasTls ? (process.env.MCP_BIND_HOST || "0.0.0.0") : "127.0.0.1";
481
-
482
500
  nodeServer.listen(port, bindHost, () => {
483
501
  const proto = hasTls ? "https" : "http";
484
502
  console.error(
@@ -7,6 +7,11 @@ import { createReadStream, statSync } from "fs";
7
7
  import { basename } from "path";
8
8
  import { authHeadersAsync, clientHeaders } from "./config.js";
9
9
  import { clearToken } from "./oauth.js";
10
+ import {
11
+ assertUploadPathAllowed,
12
+ sanitizeUploadFilename,
13
+ validateMachineName,
14
+ } from "./validate.js";
10
15
 
11
16
  const JSON_API_CONTENT_TYPE = "application/vnd.api+json";
12
17
 
@@ -115,10 +120,15 @@ export async function drupalGraphqlFetch(site, body) {
115
120
  * @throws {Error} on any non-2xx response.
116
121
  */
117
122
  export async function drupalUploadFile(site, entityType, bundle, fieldName, filePath) {
118
- const filename = basename(filePath);
119
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- filePath is a caller-supplied local upload path (validated upstream); fs access is the intended behavior
120
- const stat = statSync(filePath);
121
- const url = `${site.baseUrl}/jsonapi/${entityType}/${bundle}/${fieldName}`;
123
+ // #137: machine-name segments + path allowlist before any FS or network I/O.
124
+ validateMachineName(entityType, "entityType");
125
+ validateMachineName(bundle, "bundle");
126
+ validateMachineName(fieldName, "fieldName");
127
+ const safePath = assertUploadPathAllowed(filePath);
128
+ const filename = sanitizeUploadFilename(basename(safePath));
129
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- path allowlisted via assertUploadPathAllowed
130
+ const stat = statSync(safePath);
131
+ const url = `${site.baseUrl}/jsonapi/${encodeURIComponent(entityType)}/${encodeURIComponent(bundle)}/${encodeURIComponent(fieldName)}`;
122
132
 
123
133
  const res = await fetch(url, {
124
134
  method: "POST",
@@ -129,8 +139,8 @@ export async function drupalUploadFile(site, entityType, bundle, fieldName, file
129
139
  ...clientHeaders(),
130
140
  ...(await authHeadersAsync(site)),
131
141
  },
132
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- filePath is a caller-supplied local upload path (validated upstream); fs access is the intended behavior
133
- body: createReadStream(filePath),
142
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- path allowlisted via assertUploadPathAllowed
143
+ body: createReadStream(safePath),
134
144
  // node-fetch requires explicit size for streams to set Content-Length
135
145
  size: stat.size,
136
146
  });
@@ -190,7 +190,9 @@ async function timedFetch(url, method, { timeoutMs, fetchImpl }) {
190
190
  const controller = new AbortController();
191
191
  const timer = setTimeout(() => controller.abort(), timeoutMs);
192
192
  try {
193
- const res = await fetchImpl(url, { method, redirect: "follow", signal: controller.signal });
193
+ // #143: never auto-follow redirects a public allowlisted host can 302 to
194
+ // private/metadata. Report the redirect status; do not chase Location.
195
+ const res = await fetchImpl(url, { method, redirect: "manual", signal: controller.signal });
194
196
  return { status: res.status, error: null };
195
197
  } catch (err) {
196
198
  const reason = err?.name === "AbortError" ? `timeout after ${timeoutMs}ms` : (err?.message || "request failed");
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Safe draft default for updates of published, moderated entities (#131).
3
+ *
4
+ * When a PATCH targets a *published* entity under content_moderation and the
5
+ * caller did not pass an explicit `moderation_state`, the write can land on the
6
+ * live default revision if a server-side publish gate mis-classifies it as "no
7
+ * transition". Defaulting to `moderation_state: draft` forces a forward
8
+ * (reviewable) revision instead.
9
+ *
10
+ * Callers that genuinely want a same-state save on a published node must opt in
11
+ * by passing `moderation_state` (or the tool-level `moderationState`) explicitly.
12
+ *
13
+ * Detection is best-effort from the existing entity payload: the entity is
14
+ * treated as moderated when a `moderation_state` field is present on it, and as
15
+ * published when `status === true`. If the entity cannot be read, attributes
16
+ * are left unchanged (fail open for non-moderated bundles; server-side gate
17
+ * remains authoritative).
18
+ */
19
+
20
+ /** Default moderation state applied when none was supplied for a published moderated entity. */
21
+ export const SAFE_DRAFT_STATE = "draft";
22
+
23
+ /**
24
+ * Whether a canonical entity exposes a content_moderation state field.
25
+ * @param {?object} entity Canonical entity (or null).
26
+ * @returns {boolean}
27
+ */
28
+ export function entityLooksModerated(entity) {
29
+ if (!entity) return false;
30
+ if (entity.fields && Object.prototype.hasOwnProperty.call(entity.fields, "moderation_state")) {
31
+ return true;
32
+ }
33
+ // Tolerate a top-level promotion if a backend ever surfaces it that way.
34
+ return Object.prototype.hasOwnProperty.call(entity, "moderation_state");
35
+ }
36
+
37
+ /**
38
+ * Whether a canonical entity is currently published.
39
+ * @param {?object} entity Canonical entity (or null).
40
+ * @returns {boolean}
41
+ */
42
+ export function isPublishedEntity(entity) {
43
+ return entity?.status === true;
44
+ }
45
+
46
+ /**
47
+ * Whether the write attributes already carry an explicit moderation state.
48
+ * @param {?object} attributes Attribute map about to be sent.
49
+ * @returns {boolean}
50
+ */
51
+ export function hasExplicitModerationState(attributes) {
52
+ return Boolean(attributes && Object.prototype.hasOwnProperty.call(attributes, "moderation_state"));
53
+ }
54
+
55
+ /**
56
+ * Decide whether a draft default should be injected for this update.
57
+ * Pure helper — no I/O.
58
+ *
59
+ * @param {{attributes?: object, entity?: ?object}} input
60
+ * @returns {boolean}
61
+ */
62
+ export function shouldDefaultPublishedUpdateToDraft({ attributes, entity }) {
63
+ if (hasExplicitModerationState(attributes)) return false;
64
+ if (!isPublishedEntity(entity)) return false;
65
+ if (!entityLooksModerated(entity)) return false;
66
+ return true;
67
+ }
68
+
69
+ /**
70
+ * Apply the safe draft default when updating a published moderated entity.
71
+ * Returns a new attributes object when a default is applied; otherwise returns
72
+ * the original attributes reference unchanged.
73
+ *
74
+ * @param {object} args
75
+ * @param {object} args.backend Backend with `getEntity`.
76
+ * @param {string} args.entityType
77
+ * @param {string} args.bundle
78
+ * @param {string} args.id Entity UUID.
79
+ * @param {object} [args.attributes={}] Attribute map for the write.
80
+ * @param {?object} [args.existingEntity] Pre-fetched entity; when omitted a get is issued.
81
+ * @returns {Promise<object>} Attributes to send (possibly with moderation_state: draft).
82
+ */
83
+ export async function applySafeDraftDefault({
84
+ backend,
85
+ entityType,
86
+ bundle,
87
+ id,
88
+ attributes = {},
89
+ existingEntity,
90
+ }) {
91
+ if (hasExplicitModerationState(attributes)) return attributes;
92
+
93
+ let entity = existingEntity;
94
+ if (entity === undefined) {
95
+ try {
96
+ entity = await backend.getEntity({ entityType, bundle, id });
97
+ } catch {
98
+ // Fail open: without a readable target we cannot sniff moderation.
99
+ return attributes;
100
+ }
101
+ }
102
+ if (!shouldDefaultPublishedUpdateToDraft({ attributes, entity })) {
103
+ return attributes;
104
+ }
105
+ return { ...attributes, moderation_state: SAFE_DRAFT_STATE };
106
+ }
@@ -175,7 +175,8 @@ const PRESETS = {
175
175
  allowConfigRead: true, // read-only inspection of config
176
176
  allowConfigWrite: false,
177
177
  allowedEntityTypes: null, // read any entity type
178
- deniedEntityTypes: [],
178
+ // Secrets / governance / account entities stay denied even on broad read (#140).
179
+ deniedEntityTypes: [...SENSITIVE_DENY],
179
180
  entityRules: {
180
181
  user: {
181
182
  allowedOperations: ["read"],
@@ -192,7 +193,7 @@ const PRESETS = {
192
193
  allowConfigRead: false, // nothing implicit; opt in per site
193
194
  allowConfigWrite: false,
194
195
  allowedEntityTypes: null, // set an explicit allowlist in your config
195
- deniedEntityTypes: ["user"], // no user data at all
196
+ deniedEntityTypes: [...SENSITIVE_DENY], // includes user + secrets/governance
196
197
  entityRules: {},
197
198
  globalRedactedFields: ["pass", "mail", "field_private", "field_api_key", "field_token"],
198
199
  },
@@ -379,23 +380,33 @@ export function assertDestructiveAllowed(secConfig, entityType, id) {
379
380
  }
380
381
 
381
382
  /**
382
- * Whether a set of write attributes carries a publish action. Deliberately
383
- * limited to the unambiguous, entity-agnostic signal `status === true`; a site's
384
- * moderation-workflow state names are not knowable from a site-agnostic
385
- * connector, so publishing via `moderation_state` stays gated server-side.
383
+ * Whether a set of write attributes carries a publish action.
384
+ *
385
+ * Recognized signals:
386
+ * - `status === true` (non-moderated publish)
387
+ * - `moderation_state` equal to `"published"` (case-insensitive) — the common
388
+ * core content_moderation publish state (#139)
389
+ *
390
+ * Other workflow state names are site-specific and remain server-gated.
386
391
  * @param {object} [attributes] Attribute map for the write.
387
392
  * @returns {boolean}
388
393
  */
389
394
  export function isPublishBearing(attributes = {}) {
390
- return attributes?.status === true;
395
+ if (attributes?.status === true) return true;
396
+ // Common core content_moderation publish state. Site-specific publish state
397
+ // names remain server-gated; this closes the obvious connector-layer hole (#139).
398
+ const mod = attributes?.moderation_state;
399
+ if (typeof mod === "string" && mod.toLowerCase() === "published") return true;
400
+ return false;
391
401
  }
392
402
 
393
403
  /**
394
404
  * Local, fail-fast publish gate, symmetric with assertDestructiveAllowed. When
395
405
  * the connector is not permitted to publish (allowPublish false — the default in
396
- * every preset except `development`), a write carrying `status: true` is refused
397
- * before the round-trip, rather than being silently dropped by a moderated-bundle
398
- * retry or a server-side gate (see #111/#114). Client-side convenience only — the
406
+ * every preset except `development`), a write that is publish-bearing
407
+ * (`status: true` or `moderation_state: "published"`) is refused before the
408
+ * round-trip, rather than being silently dropped by a moderated-bundle retry or
409
+ * a server-side gate (see #111/#114/#139). Client-side convenience only — the
399
410
  * remote Drupal's own permissions remain the real authority.
400
411
  * @param {object} secConfig Resolved security config.
401
412
  * @param {object} [attributes] Attribute map for the write.
@@ -407,9 +418,8 @@ export function assertPublishAllowed(secConfig, attributes = {}) {
407
418
  if (isPublishBearing(attributes)) {
408
419
  throw new SecurityError(
409
420
  "Publishing is disabled for this connector (allowPublish: false). " +
410
- "Blocked: a write carrying status:true. " +
411
- "To enable, set security.allowPublish = true in your config; otherwise publish " +
412
- "via an operator/server-gated path (e.g. a moderation transition)."
421
+ "Blocked: a write carrying status:true or moderation_state:published. " +
422
+ "To enable, set security.allowPublish = true in your config."
413
423
  );
414
424
  }
415
425
  }