aiquila-mcp 0.4.2 → 0.4.4

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.
package/README.md CHANGED
@@ -92,6 +92,8 @@ See the [Docker setup guide](https://github.com/elgorro/aiquila/blob/main/docs/m
92
92
  | `MCP_AUTH_ENABLED` | No | `true` to enable OAuth for remote clients |
93
93
  | `MCP_AUTH_SECRET` | If auth | `openssl rand -hex 32` |
94
94
  | `MCP_AUTH_ISSUER` | If auth | public HTTPS URL of this server |
95
+ | `MCP_ALLOWED_HOSTS` | No | extra hostnames for DNS rebinding protection |
96
+ | `MCP_CORS_ORIGINS` | No | extra browser origins allowed via CORS |
95
97
  | `LOG_LEVEL` | No | `trace`/`debug`/`info`/`warn`/`error`/`fatal` |
96
98
 
97
99
  ## Requirements
@@ -0,0 +1,23 @@
1
+ // SPDX-License-Identifier: MIT
2
+ /**
3
+ * Stylesheet for the OAuth login page.
4
+ *
5
+ * Served as a standalone document at LOGIN_STYLESHEET_PATH rather than inlined
6
+ * in a <style> block, so the login page's CSP can drop 'unsafe-inline' entirely.
7
+ * Keep this free of any interpolated/user-supplied value — it is served verbatim.
8
+ */
9
+ export const LOGIN_STYLESHEET = `body { font-family: sans-serif; background: #f4f6f8; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; }
10
+ .card { background: #fff; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,.12); padding: 2rem; width: 100%; max-width: 360px; }
11
+ h1 { font-size: 1.3rem; margin: 0 0 .25rem; }
12
+ .consent-banner { background: #eef6fc; border: 1px solid #b3d7f0; border-radius: 4px; padding: .75rem 1rem; margin-bottom: 1.25rem; font-size: .85rem; color: #333; line-height: 1.6; }
13
+ .consent-banner strong { color: #0082c9; }
14
+ .consent-banner .detail { display: block; margin-top: .25rem; }
15
+ .consent-banner code { background: #d6ecf9; border-radius: 3px; padding: 0 .3em; font-size: .85em; word-break: break-all; }
16
+ label { display: block; font-size: .85rem; font-weight: 600; margin-bottom: .25rem; }
17
+ input[type=text], input[type=password] { width: 100%; box-sizing: border-box; padding: .55rem .75rem; border: 1px solid #ccc; border-radius: 4px; font-size: 1rem; margin-bottom: 1rem; }
18
+ button { width: 100%; padding: .65rem; background: #0082c9; color: #fff; border: none; border-radius: 4px; font-size: 1rem; cursor: pointer; }
19
+ button:hover { background: #006fa3; }
20
+ .error { background: #fdecea; color: #c0392b; border-radius: 4px; padding: .6rem .9rem; margin-bottom: 1rem; font-size: .9rem; }
21
+ `;
22
+ /** Public path the login page links its stylesheet from. */
23
+ export const LOGIN_STYLESHEET_PATH = '/auth/login.css';
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- import { renderLoginForm } from './provider.js';
2
+ import { renderLoginForm, applySecurityHeaders } from './provider.js';
3
3
  import { logger } from '../logger.js';
4
4
  export function loginHandler(provider) {
5
5
  return async (req, res) => {
@@ -11,7 +11,7 @@ export function loginHandler(provider) {
11
11
  return;
12
12
  }
13
13
  if (!username || !password || !client_id || !redirect_uri || !code_challenge) {
14
- res
14
+ applySecurityHeaders(res)
15
15
  .status(400)
16
16
  .type('html')
17
17
  .send(renderLoginForm({
@@ -26,7 +26,7 @@ export function loginHandler(provider) {
26
26
  }
27
27
  const client = await provider.clientsStore.getClient(client_id);
28
28
  if (!client) {
29
- res
29
+ applySecurityHeaders(res)
30
30
  .status(400)
31
31
  .type('html')
32
32
  .send(renderLoginForm({
@@ -40,7 +40,7 @@ export function loginHandler(provider) {
40
40
  return;
41
41
  }
42
42
  if (!client.redirect_uris.map(String).includes(redirect_uri)) {
43
- res
43
+ applySecurityHeaders(res)
44
44
  .status(400)
45
45
  .type('html')
46
46
  .send(renderLoginForm({
@@ -64,7 +64,7 @@ export function loginHandler(provider) {
64
64
  });
65
65
  if (!ncResp.ok) {
66
66
  logger.warn({ user: username, status: ncResp.status }, '[auth] Login failed');
67
- res
67
+ applySecurityHeaders(res)
68
68
  .status(200)
69
69
  .type('html')
70
70
  .send(renderLoginForm({
@@ -95,7 +95,7 @@ export function loginHandler(provider) {
95
95
  }
96
96
  catch (err) {
97
97
  logger.error({ user: username, err }, '[auth] Login error');
98
- res
98
+ applySecurityHeaders(res)
99
99
  .status(200)
100
100
  .type('html')
101
101
  .send(renderLoginForm({
@@ -3,6 +3,7 @@ import { createHash, timingSafeEqual } from 'node:crypto';
3
3
  import { SignJWT, jwtVerify } from 'jose';
4
4
  import { InvalidGrantError, InvalidTokenError, } from '@modelcontextprotocol/sdk/server/auth/errors.js';
5
5
  import { ClientsStore, CodeStore, RefreshStore } from './store.js';
6
+ import { LOGIN_STYLESHEET_PATH } from './login-page-css.js';
6
7
  import { logger } from '../logger.js';
7
8
  // --- JWT helpers (HMAC-SHA256 via jose) ---
8
9
  async function signJwt(payload, secret, expiresInSecs) {
@@ -37,6 +38,22 @@ function escapeHtml(s) {
37
38
  .replace(/"/g, '&quot;')
38
39
  .replace(/'/g, '&#39;');
39
40
  }
41
+ /**
42
+ * Security headers for every HTML response served by the OAuth login flow.
43
+ *
44
+ * The page has no inline <script> and no inline styles (the stylesheet is served
45
+ * separately from LOGIN_STYLESHEET_PATH), so the policy can deny everything by
46
+ * default and allow only same-origin styles.
47
+ */
48
+ export const LOGIN_PAGE_CSP = "frame-ancestors 'none'; default-src 'none'; style-src 'self'; form-action 'self'; base-uri 'none'";
49
+ /** Applies the login-flow security headers to a response. Returns the response for chaining. */
50
+ export function applySecurityHeaders(res) {
51
+ return res
52
+ .set('X-Frame-Options', 'DENY')
53
+ .set('X-Content-Type-Options', 'nosniff')
54
+ .set('Referrer-Policy', 'no-referrer')
55
+ .set('Content-Security-Policy', LOGIN_PAGE_CSP);
56
+ }
40
57
  export function renderLoginForm(opts) {
41
58
  return `<!DOCTYPE html>
42
59
  <html lang="en">
@@ -44,21 +61,7 @@ export function renderLoginForm(opts) {
44
61
  <meta charset="UTF-8">
45
62
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
46
63
  <title>AIquila – Sign in with Nextcloud</title>
47
- <style>
48
- body { font-family: sans-serif; background: #f4f6f8; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; }
49
- .card { background: #fff; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,.12); padding: 2rem; width: 100%; max-width: 360px; }
50
- h1 { font-size: 1.3rem; margin: 0 0 .25rem; }
51
- p.sub { color: #555; font-size: .9rem; margin: 0 0 1.5rem; }
52
- .consent-banner { background: #eef6fc; border: 1px solid #b3d7f0; border-radius: 4px; padding: .75rem 1rem; margin-bottom: 1.25rem; font-size: .85rem; color: #333; line-height: 1.6; }
53
- .consent-banner strong { color: #0082c9; }
54
- .consent-banner .detail { display: block; margin-top: .25rem; }
55
- .consent-banner code { background: #d6ecf9; border-radius: 3px; padding: 0 .3em; font-size: .85em; word-break: break-all; }
56
- label { display: block; font-size: .85rem; font-weight: 600; margin-bottom: .25rem; }
57
- input[type=text], input[type=password] { width: 100%; box-sizing: border-box; padding: .55rem .75rem; border: 1px solid #ccc; border-radius: 4px; font-size: 1rem; margin-bottom: 1rem; }
58
- button { width: 100%; padding: .65rem; background: #0082c9; color: #fff; border: none; border-radius: 4px; font-size: 1rem; cursor: pointer; }
59
- button:hover { background: #006fa3; }
60
- .error { background: #fdecea; color: #c0392b; border-radius: 4px; padding: .6rem .9rem; margin-bottom: 1rem; font-size: .9rem; }
61
- </style>
64
+ <link rel="stylesheet" href="${LOGIN_STYLESHEET_PATH}">
62
65
  </head>
63
66
  <body>
64
67
  <div class="card">
@@ -96,9 +99,7 @@ export class NextcloudOAuthProvider {
96
99
  return this._clientsStore;
97
100
  }
98
101
  async authorize(client, params, res) {
99
- res
100
- .set('X-Frame-Options', 'DENY')
101
- .set('Content-Security-Policy', "frame-ancestors 'none'; default-src 'self'; style-src 'unsafe-inline'")
102
+ applySecurityHeaders(res)
102
103
  .status(200)
103
104
  .type('html')
104
105
  .send(renderLoginForm({
@@ -9,6 +9,7 @@ import { createServer, SERVER_VERSION } from '../server.js';
9
9
  import { NextcloudOAuthProvider } from '../auth/provider.js';
10
10
  import { probeStateDir, StateDirNotWritableError, stateUnwritableMessage, markStateUnwritableWarned, } from '../auth/store.js';
11
11
  import { loginHandler } from '../auth/login.js';
12
+ import { LOGIN_STYLESHEET, LOGIN_STYLESHEET_PATH } from '../auth/login-page-css.js';
12
13
  import { isPublicRequest } from './lazy-auth.js';
13
14
  import { logger } from '../logger.js';
14
15
  import { fetchStatus } from '../client/ocs.js';
@@ -133,7 +134,70 @@ export async function startHttp() {
133
134
  allowedHosts = [...new Set(['localhost', '127.0.0.1', ...extras])];
134
135
  }
135
136
  }
137
+ // Explicit CORS. Browser-based MCP clients need these headers; previously the
138
+ // server sent none and relied entirely on the reverse proxy. The issuer origin
139
+ // is trusted by default (it serves the login page), and MCP_CORS_ORIGINS adds
140
+ // extra origins — comma-separated, whitespace-trimmed, same idiom as
141
+ // MCP_ALLOWED_HOSTS. Origins are matched exactly and echoed back; the wildcard
142
+ // '*' is never sent, since these endpoints are credentialed.
143
+ const allowedOrigins = new Set();
144
+ const issuerForCors = process.env.MCP_AUTH_ISSUER;
145
+ if (authEnabled && issuerForCors) {
146
+ try {
147
+ allowedOrigins.add(new URL(issuerForCors).origin);
148
+ }
149
+ catch {
150
+ // Malformed issuer URL — the validation below will throw a clear error.
151
+ }
152
+ }
153
+ const extraOrigins = process.env.MCP_CORS_ORIGINS;
154
+ if (extraOrigins) {
155
+ for (const raw of extraOrigins.split(',')) {
156
+ const trimmed = raw.trim();
157
+ if (!trimmed)
158
+ continue;
159
+ try {
160
+ allowedOrigins.add(new URL(trimmed).origin);
161
+ }
162
+ catch {
163
+ logger.warn({ origin: trimmed }, '[startup] Ignoring malformed MCP_CORS_ORIGINS entry');
164
+ }
165
+ }
166
+ }
136
167
  const app = createMcpExpressApp({ host, allowedHosts });
168
+ // Mounted ahead of every other route so that preflights are answered before
169
+ // the auth chain runs — an OPTIONS request carries no Authorization header
170
+ // and would otherwise be rejected with 401 by requireBearerAuth.
171
+ app.use((req, res, next) => {
172
+ const origin = req.headers?.origin;
173
+ if (typeof origin === 'string' && allowedOrigins.has(origin)) {
174
+ res.setHeader('Access-Control-Allow-Origin', origin);
175
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
176
+ res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID');
177
+ res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id, WWW-Authenticate');
178
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
179
+ res.setHeader('Access-Control-Max-Age', '86400');
180
+ }
181
+ // Always vary on Origin: the response differs per origin even when no
182
+ // headers are added, so a shared cache must not reuse one for another.
183
+ res.setHeader('Vary', 'Origin');
184
+ if (req.method === 'OPTIONS') {
185
+ res.status(204).end();
186
+ return;
187
+ }
188
+ next();
189
+ });
190
+ // Stylesheet for the OAuth login page. Served as its own document so the page
191
+ // needs no inline <style>, which lets its CSP drop 'unsafe-inline'. Must stay
192
+ // ahead of the auth middleware — the login page is shown to anonymous users.
193
+ app.get(LOGIN_STYLESHEET_PATH, (_req, res) => {
194
+ res
195
+ .set('Content-Type', 'text/css; charset=utf-8')
196
+ .set('Cache-Control', 'public, max-age=86400')
197
+ .set('X-Content-Type-Options', 'nosniff')
198
+ .status(200)
199
+ .send(LOGIN_STYLESHEET);
200
+ });
137
201
  // Simple health check — bypasses all auth middleware so Docker health checks
138
202
  // work even before OAuth is fully configured or TLS is verified.
139
203
  app.get('/health', (_req, res) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiquila-mcp",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Nextcloud MCP server — files, calendar, contacts, mail, maps, notes, tasks & 120+ more tools",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",