diff --git a/.limetech/ai-review-markers/fix-api-client-identity-ws-policy-73041c5cc2c5.json b/.limetech/ai-review-markers/fix-api-client-identity-ws-policy-73041c5cc2c5.json new file mode 100644 index 0000000000..6d8d333f32 --- /dev/null +++ b/.limetech/ai-review-markers/fix-api-client-identity-ws-policy-73041c5cc2c5.json @@ -0,0 +1,8 @@ +{ + "disposition": "PATCH", + "forecast_commit": "ee919505185a59be4ac687f6452d0db18520be50", + "reviewed_sha": "d7f2783a69c21fa3ccab91a6dc78f73d5823381a", + "schema": "limetech.ai-review-marker.v2", + "stage": "final", + "unresolved_proportionality_findings": [] +} diff --git a/api/src/unraid-api/auth/fastify-throttler.guard.spec.ts b/api/src/unraid-api/auth/fastify-throttler.guard.spec.ts new file mode 100644 index 0000000000..882bd9ea50 --- /dev/null +++ b/api/src/unraid-api/auth/fastify-throttler.guard.spec.ts @@ -0,0 +1,113 @@ +import { Reflector } from '@nestjs/core'; +import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host.js'; +import { ThrottlerStorageService } from '@nestjs/throttler'; +import { mkdtemp, rm } from 'node:fs/promises'; +import http from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import Fastify from 'fastify'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { FastifyThrottlerGuard } from '@app/unraid-api/auth/fastify-throttler.guard.js'; + +describe('FastifyThrottlerGuard client identity', () => { + const cleanups: Array<() => Promise | void> = []; + afterEach(async () => { + for (const cleanup of cleanups.reverse()) await cleanup(); + cleanups.length = 0; + }); + + const createServer = async () => { + const storage = new ThrottlerStorageService(); + cleanups.push(() => storage.onApplicationShutdown()); + const guard = new FastifyThrottlerGuard([{ ttl: 60_000, limit: 2 }], storage, new Reflector()); + await guard.onModuleInit(); + const server = Fastify({ trustProxy: 'loopback' }); + cleanups.push(() => server.close()); + class Controller { + authorize() {} + callback() {} + } + for (const path of ['/authorize', '/callback']) { + const handler = + path === '/authorize' ? Controller.prototype.authorize : Controller.prototype.callback; + server.get(path, async (req, res) => { + const context = new ExecutionContextHost([req, res], Controller, handler); + try { + await guard.canActivate(context); + return res.code(204).send(); + } catch (error) { + const status = + error instanceof Error && + 'getStatus' in error && + typeof error.getStatus === 'function' + ? error.getStatus() + : 500; + return res.code(status).send(); + } + }); + } + return server; + }; + + it('uses the actual TCP peer even when loopback clients forge forwarded headers', async () => { + const server = await createServer(); + for (const forwarded of ['192.0.2.1', '192.0.2.2']) { + expect( + ( + await server.inject({ + url: '/authorize', + remoteAddress: '127.0.0.2', + headers: { 'x-forwarded-for': forwarded }, + }) + ).statusCode + ).toBe(204); + } + expect( + ( + await server.inject({ + url: '/authorize', + remoteAddress: '127.0.0.2', + headers: { 'x-forwarded-for': '192.0.2.3' }, + }) + ).statusCode + ).toBe(429); + expect((await server.inject({ url: '/authorize', remoteAddress: '127.0.0.3' })).statusCode).toBe( + 204 + ); + expect((await server.inject({ url: '/callback', remoteAddress: '127.0.0.2' })).statusCode).toBe( + 204 + ); + }); + + it('keeps Unix socket clients independent and rejects missing or ambiguous identity', async () => { + const directory = await mkdtemp(join(tmpdir(), 'api-ip-')); + cleanups.push(() => rm(directory, { recursive: true, force: true })); + const server = await createServer(); + const socketPath = join(directory, 'api.sock'); + await server.listen({ path: socketPath }); + const send = (address?: string) => + new Promise((resolve, reject) => { + const request = http.get( + { + socketPath, + path: '/authorize', + headers: address ? { 'x-forwarded-for': address } : {}, + }, + (response) => { + response.resume(); + response.on('end', () => resolve(response.statusCode)); + } + ); + request.on('error', reject); + }); + expect(await send('192.0.2.1')).toBe(204); + expect(await send('192.0.2.1')).toBe(204); + expect(await send('192.0.2.1')).toBe(429); + expect(await send('192.0.2.2')).toBe(204); + expect(await send()).toBe(400); + expect(await send('192.0.2.1, 192.0.2.2')).toBe(400); + expect(await send('invalid')).toBe(400); + }); +}); diff --git a/api/src/unraid-api/auth/fastify-throttler.guard.ts b/api/src/unraid-api/auth/fastify-throttler.guard.ts index 48de4509cf..f951be04a5 100644 --- a/api/src/unraid-api/auth/fastify-throttler.guard.ts +++ b/api/src/unraid-api/auth/fastify-throttler.guard.ts @@ -1,4 +1,4 @@ -import { ExecutionContext, Inject, Injectable, Optional } from '@nestjs/common'; +import { BadRequestException, ExecutionContext, Inject, Injectable, Optional } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { GqlExecutionContext } from '@nestjs/graphql'; import { @@ -9,6 +9,7 @@ import { ThrottlerStorage, ThrottlerStorageService, } from '@nestjs/throttler'; +import { isIP } from 'node:net'; import { type FastifyRequest } from 'fastify'; @@ -26,9 +27,17 @@ export class FastifyThrottlerGuard extends ThrottlerGuard { ); } - protected async getTracker(req: Record): Promise { - const request = req as unknown as FastifyRequest; - return request.ip ?? request.ips?.[0] ?? request.headers?.['x-forwarded-for'] ?? '0.0.0.0'; + protected async getTracker(request: FastifyRequest): Promise { + const peerAddress = request.raw.socket.remoteAddress; + if (peerAddress) return peerAddress; + + // Unix sockets have no peer IP. nginx overwrites this header with its TCP client address. + const forwardedAddress = request.headers['x-forwarded-for']; + if (typeof forwardedAddress === 'string' && isIP(forwardedAddress)) { + return forwardedAddress; + } + + throw new BadRequestException('Client IP address is unavailable'); } getRequestResponse(context: ExecutionContext) { diff --git a/api/src/unraid-api/graph/graph.module.ts b/api/src/unraid-api/graph/graph.module.ts index 902505a3a0..8a947b942f 100644 --- a/api/src/unraid-api/graph/graph.module.ts +++ b/api/src/unraid-api/graph/graph.module.ts @@ -19,7 +19,7 @@ import '@app/unraid-api/graph/auth/auth-action.enum.js'; import { createDynamicIntrospectionPlugin } from '@app/unraid-api/graph/introspection-plugin.js'; import { - graphqlWsOptions, + createGraphqlWsOptions, maxGraphqlTokens, requestProtection, } from '@app/unraid-api/graph/request-protection.js'; @@ -61,7 +61,7 @@ import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js'; createDynamicIntrospectionPlugin(isSandboxEnabled), createSandboxPlugin(), ] as any[], - subscriptions: { 'graphql-ws': graphqlWsOptions }, + subscriptions: { 'graphql-ws': createGraphqlWsOptions(isSandboxEnabled) }, buildSchemaOptions: { dateScalarMode: 'isoDate', // Only add directive when not in test environment to avoid GraphQL version conflicts diff --git a/api/src/unraid-api/graph/introspection-plugin.ts b/api/src/unraid-api/graph/introspection-plugin.ts index d3c62bfb0a..1a9372cc26 100644 --- a/api/src/unraid-api/graph/introspection-plugin.ts +++ b/api/src/unraid-api/graph/introspection-plugin.ts @@ -64,7 +64,7 @@ const hasBlockedIntrospectionField = ( return blocked; }); -const isBlockedIntrospectionOperation = ( +export const isBlockedIntrospectionOperation = ( operation: OperationDefinitionNode, document: DocumentNode ): boolean => { diff --git a/api/src/unraid-api/graph/request-protection.spec.ts b/api/src/unraid-api/graph/request-protection.spec.ts index a034f6b4a0..d8520b60fa 100644 --- a/api/src/unraid-api/graph/request-protection.spec.ts +++ b/api/src/unraid-api/graph/request-protection.spec.ts @@ -1,7 +1,13 @@ +import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; + import { buildSchema, parse, validate } from 'graphql'; +import { createClient } from 'graphql-ws'; +import { useServer } from 'graphql-ws/use/ws'; import { describe, expect, it } from 'vitest'; +import WebSocket, { WebSocketServer } from 'ws'; -import { requestProtection } from '@app/unraid-api/graph/request-protection.js'; +import { createGraphqlWsOptions, requestProtection } from '@app/unraid-api/graph/request-protection.js'; const schema = buildSchema('type Query { value: String, child: Query }'); @@ -60,3 +66,76 @@ describe('requestProtection', () => { expect(validateRequest('query First { value } query Second { value }')).toHaveLength(0); }); }); + +describe('WebSocket introspection policy', () => { + it('checks the selected operation before execution and reads sandbox changes on the same connection', async () => { + const graphql: typeof import('graphql') = createRequire(import.meta.url)('graphql'); + const socketSchema = graphql.buildSchema('type Query { value: String }'); + let resolverCalls = 0; + let sandboxEnabled = false; + socketSchema.getQueryType()!.getFields().value.resolve = () => { + resolverCalls++; + return 'ok'; + }; + const server = createServer(); + const sockets = new WebSocketServer({ server, path: '/graphql' }); + const disposable = useServer( + { + schema: socketSchema, + ...createGraphqlWsOptions(() => sandboxEnabled), + }, + sockets + ); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected TCP listener'); + const client = createClient({ + url: `ws://127.0.0.1:${address.port}/graphql`, + webSocketImpl: WebSocket, + lazy: false, + retryAttempts: 0, + }); + const run = async (query: string, operationName?: string) => { + const iterator = client.iterate({ query, operationName }); + try { + return (await iterator.next()).value; + } finally { + await iterator.return?.(); + } + }; + try { + for (const query of [ + '{ __schema { queryType { name } } value }', + '{ metadata: __type(name: "Query") { name } value }', + 'query { ...Metadata value } fragment Metadata on Query { __schema { queryType { name } } }', + '{ ... on Query { __schema @include(if: true) { queryType { name } } } value }', + ]) { + await expect(run(query)).rejects.toEqual([ + expect.objectContaining({ extensions: { code: 'INTROSPECTION_DISABLED' } }), + ]); + } + expect(resolverCalls).toBe(0); + const operations = + 'query Normal { value __typename } query Metadata { __schema { queryType { name } } }'; + expect((await run(operations, 'Normal'))?.data).toEqual({ + value: 'ok', + __typename: 'Query', + }); + await expect(run(operations, 'Metadata')).rejects.toBeDefined(); + sandboxEnabled = true; + expect((await run(operations, 'Metadata'))?.data).toEqual({ + __schema: { queryType: { name: 'Query' } }, + }); + sandboxEnabled = false; + await expect(run(operations, 'Metadata')).rejects.toBeDefined(); + const aliases = Array.from({ length: 101 }, (_, index) => `a${index}: value`).join(' '); + await expect(run(`{ ${aliases} }`)).rejects.toBeDefined(); + } finally { + await client.dispose(); + await disposable.dispose(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ); + } + }); +}); diff --git a/api/src/unraid-api/graph/request-protection.ts b/api/src/unraid-api/graph/request-protection.ts index e1194028fc..1f3239b403 100644 --- a/api/src/unraid-api/graph/request-protection.ts +++ b/api/src/unraid-api/graph/request-protection.ts @@ -4,6 +4,8 @@ import type { DocumentNode, GraphQLSchema } from 'graphql'; import type { SubscribePayload } from 'graphql-ws'; import { ApolloArmor } from '@escape.tech/graphql-armor'; +import { isBlockedIntrospectionOperation } from '@app/unraid-api/graph/introspection-plugin.js'; + export const maxGraphqlTokens = 5_000; const graphqlForWs: typeof import('graphql') = createRequire(import.meta.url)('graphql'); @@ -26,11 +28,24 @@ export const requestProtection = new ApolloArmor({ const websocketValidationRules = [...graphqlForWs.specifiedRules, ...requestProtection.validationRules]; -export const graphqlWsOptions = { +export const createGraphqlWsOptions = (isSandboxEnabled: () => boolean) => ({ path: '/graphql', onSubscribe: (_context: unknown, _id: string, payload: SubscribePayload) => { try { - graphqlForWs.parse(payload.query, { maxTokens: maxGraphqlTokens }); + const document = graphqlForWs.parse(payload.query, { maxTokens: maxGraphqlTokens }); + const operation = graphqlForWs.getOperationAST(document, payload.operationName); + if ( + !isSandboxEnabled() && + operation && + isBlockedIntrospectionOperation(operation, document) + ) { + return [ + new graphqlForWs.GraphQLError( + 'GraphQL introspection is not allowed, but the current request is for introspection.', + { extensions: { code: 'INTROSPECTION_DISABLED' } } + ), + ]; + } } catch (error) { if (error instanceof graphqlForWs.GraphQLError) return [error]; throw error; @@ -40,4 +55,4 @@ export const graphqlWsOptions = { // Nest passes this option to graphql-ws, although its subscription config omits the type. validate: (schema: GraphQLSchema, document: DocumentNode) => graphqlForWs.validate(schema, document, websocketValidationRules), -}; +}); diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/rc-nginx.modification.spec.ts b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/rc-nginx.modification.spec.ts new file mode 100644 index 0000000000..59346caec6 --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/rc-nginx.modification.spec.ts @@ -0,0 +1,95 @@ +import { Logger } from '@nestjs/common'; +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +import { createPatch } from 'diff'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import RcNginxModification from '@app/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.js'; + +describe('rc.nginx client identity lifecycle', () => { + let directory: string; + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'api-nginx-')); + }); + afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + it('patches integrated API versions, survives reapply, and rolls back cleanly', async () => { + const original = `CONNECT_CONFIG="existing" +build_locations(){ + cat <<- 'EOF' +\tlocation /graphql { +\t proxy_set_header X-Forwarded-For \\$http_x_forwarded_for; +\t proxy_pass http://unix:/var/run/unraid-api.sock:/graphql; +\t proxy_set_header Host \\$host; +\t} +\tEOF +} +`; + const file = join(directory, 'rc.nginx'); + const previous = original.replace('CONNECT_CONFIG="existing"', 'CONNECT_CONFIG="previous"'); + await writeFile(file, previous); + await writeFile(`${file}.patch`, createPatch(file, original, previous)); + const modification = new RcNginxModification(new Logger()); + modification.filePath = file; + expect((await modification.shouldApply()).shouldApply).toBe(true); + await modification.apply(); + const patched = await readFile(file, 'utf8'); + expect(patched).toContain('proxy_set_header X-Forwarded-For $remote_addr;'); + expect(patched).not.toContain('$http_x_forwarded_for'); + expect(patched.match(/proxy_set_header X-Forwarded-For/g)).toHaveLength(1); + const { stdout } = await promisify(execFile)('/bin/bash', [ + '-c', + 'source "$1"; build_locations', + 'nginx-test', + file, + ]); + expect(stdout).toContain('proxy_set_header X-Forwarded-For $remote_addr;'); + await modification.apply(); + expect(await readFile(file, 'utf8')).toBe(patched); + await modification.rollback(); + expect(await readFile(file, 'utf8')).toBe(original); + }); + it('emits a valid client address variable from the bundled nginx generator', async () => { + const original = await readFile( + new URL('./__fixtures__/downloaded/rc.nginx', import.meta.url), + 'utf8' + ); + const file = join(directory, 'rc.nginx'); + await writeFile(file, original); + const modification = new RcNginxModification(new Logger()); + modification.filePath = file; + await modification.apply(); + const patched = await readFile(file, 'utf8'); + const start = patched.indexOf('build_locations(){'); + const end = patched.indexOf('\n}\n', start); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + const generator = join(directory, 'locations.sh'); + const locations = join(directory, 'locations.conf'); + await writeFile(generator, patched.slice(start, end + 3)); + await promisify(execFile)('/bin/bash', [ + '-c', + 'source "$1"; LOCATIONS="$2"; build_locations', + 'nginx-test', + generator, + locations, + ]); + const generated = await readFile(locations, 'utf8'); + const header = generated + .split('\n') + .filter((line) => line.includes('proxy_set_header X-Forwarded-For')); + expect(header.map((line) => line.trim())).toEqual([ + 'proxy_set_header X-Forwarded-For $remote_addr;', + ]); + await modification.apply(); + expect(await readFile(file, 'utf8')).toBe(patched); + await modification.rollback(); + expect(await readFile(file, 'utf8')).toBe(original); + }); +}); diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/rc.nginx.modified.snapshot b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/rc.nginx.modified.snapshot index 7c555fda54..6547894484 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/rc.nginx.modified.snapshot +++ b/api/src/unraid-api/unraid-file-modifier/modifications/__test__/snapshots/rc.nginx.modified.snapshot @@ -421,6 +421,7 @@ build_locations(){ allow all; error_log /dev/null crit; proxy_pass http://unix:/var/run/unraid-api.sock:/graphql; + proxy_set_header X-Forwarded-For $remote_addr; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header Upgrade $http_upgrade; diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/patches/rc-nginx.patch b/api/src/unraid-api/unraid-file-modifier/modifications/patches/rc-nginx.patch index fbac95e0bc..2fa9b0725e 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/patches/rc-nginx.patch +++ b/api/src/unraid-api/unraid-file-modifier/modifications/patches/rc-nginx.patch @@ -44,7 +44,19 @@ Index: /etc/rc.d/rc.nginx T=' ' if check && [[ $1 == lo ]]; then if [[ $IPV4 == yes ]]; then -@@ -566,11 +584,11 @@ +@@ -401,10 +419,11 @@ + # + location /graphql { + allow all; + error_log /dev/null crit; + proxy_pass http://unix:/var/run/unraid-api.sock:/graphql; ++ proxy_set_header X-Forwarded-For $remote_addr; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_cache_bypass $http_upgrade; +@@ -566,11 +585,11 @@ # extract common name from cert CERTNAME=$(openssl x509 -noout -subject -nameopt multiline -in $CERTPATH | sed -n 's/ *commonName *= //p') # define CSP frame-ancestors for cert @@ -57,7 +69,7 @@ Index: /etc/rc.d/rc.nginx WANIP6=$(curl https://wanip6.unraid.net/ 2>/dev/null) fi if [[ $CERTNAME == *\.myunraid\.net ]]; then -@@ -660,14 +678,14 @@ +@@ -660,14 +679,14 @@ echo "NGINX_WANFQDN=\"$WANFQDN\"" >>$INI echo "NGINX_WANFQDN6=\"$WANFQDN6\"" >>$INI # defined if ts_bundle.pem present: diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.ts index 6b1c717a3a..f05d8a6c19 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.ts +++ b/api/src/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.ts @@ -7,7 +7,7 @@ import { } from '@app/unraid-api/unraid-file-modifier/file-modification.js'; /** - * Patch rc.nginx on < Unraid 7.2.0 to read the updated connect & api config files + * Preserve client identity for the API proxy and backport legacy Connect configuration. * * Backport of https://github.com/unraid/webgui/pull/2269 */ @@ -15,34 +15,23 @@ export default class RcNginxModification extends FileModification { public filePath: string = '/etc/rc.d/rc.nginx' as const; id: string = 'rc-nginx'; - /** - * Generate a patch for the rc.nginx file - * - * Should result in the same patch as - * https://patch-diff.githubusercontent.com/raw/unraid/webgui/pull/2269.patch - * - * @param overridePath - The path to override the default file path - * @returns The patch for the rc.nginx file - */ protected async generatePatch(overridePath?: string): Promise { if (!(await fileExists(this.filePath))) { throw new Error(`File ${this.filePath} not found.`); } const fileContent = await readFile(this.filePath, 'utf8'); - if (!fileContent.includes('MYSERVERS=')) { - throw new Error(`MYSERVERS not found in the file; incorrect target?`); - } - - let newContent = fileContent.replace( - 'MYSERVERS="/boot/config/plugins/dynamix.my.servers/myservers.cfg"', - `CONNECT_CONFIG="/boot/config/plugins/dynamix.my.servers/configs/connect.json" + let newContent = fileContent; + if (fileContent.includes('MYSERVERS=')) { + newContent = newContent.replace( + 'MYSERVERS="/boot/config/plugins/dynamix.my.servers/myservers.cfg"', + `CONNECT_CONFIG="/boot/config/plugins/dynamix.my.servers/configs/connect.json" API_UTILS="/usr/local/share/dynamix.unraid.net/scripts/api_utils.sh"` - ); + ); - if (!newContent.includes('check_remote_access()')) { - newContent = newContent.replace( - '# create listening ports', - `# check if remote access should be enabled + if (!newContent.includes('check_remote_access()')) { + newContent = newContent.replace( + '# create listening ports', + `# check if remote access should be enabled check_remote_access(){ # Check if connect plugin is enabled using api_utils.sh if [[ -f $API_UTILS ]] && $API_UTILS is_api_plugin_enabled "unraid-api-plugin-connect"; then @@ -60,38 +49,57 @@ check_remote_access(){ } # create listening ports` - ); - } - - newContent = newContent.replace( - `if [[ -L /usr/local/sbin/unraid-api ]] && grep -qs 'wanaccess="yes"' $MYSERVERS && ! grep -qs 'username=""' $MYSERVERS; then`, - `if [[ -L /usr/local/sbin/unraid-api ]] && check_remote_access; then` - ); + ); + } - newContent = newContent.replace( - 'for NET in ${!NET_FQDN6[@]}; do', - 'for NET in "${!NET_FQDN6[@]}"; do' - ); - newContent = newContent.replace( - 'for NET in ${!NET_FQDN[@]}; do', - 'for NET in "${!NET_FQDN[@]}"; do' - ); + newContent = newContent.replace( + `if [[ -L /usr/local/sbin/unraid-api ]] && grep -qs 'wanaccess="yes"' $MYSERVERS && ! grep -qs 'username=""' $MYSERVERS; then`, + `if [[ -L /usr/local/sbin/unraid-api ]] && check_remote_access; then` + ); - // Add robots.txt Access-Control-Allow-Origin header if not already present - if (!newContent.includes('#robots.txt any origin')) { newContent = newContent.replace( - 'location = /robots.txt {', - // prettier-ignore - `location = /robots.txt { -\t add_header Access-Control-Allow-Origin *; #robots.txt any origin` + 'for NET in ${!NET_FQDN6[@]}; do', + 'for NET in "${!NET_FQDN6[@]}"; do' + ); + newContent = newContent.replace( + 'for NET in ${!NET_FQDN[@]}; do', + 'for NET in "${!NET_FQDN[@]}"; do' ); + + // Add robots.txt Access-Control-Allow-Origin header if not already present + if (!newContent.includes('#robots.txt any origin')) { + newContent = newContent.replace( + 'location = /robots.txt {', + // prettier-ignore + `location = /robots.txt { +\t add_header Access-Control-Allow-Origin *; #robots.txt any origin` + ); + } } + newContent = newContent.replace( + /(^[\t ]*location \/graphql \{\n)([\s\S]*?)(^[\t ]*\})/gm, + (match: string, opening: string, directives: string, closing: string) => { + const proxyLine = + /^[\t ]*proxy_pass http:\/\/unix:\/var\/run\/unraid-api\.sock:\/graphql;[^\n]*\n/m; + if (!proxyLine.test(directives)) return match; + const withoutForwardedFor = directives.replace( + /^[\t ]*proxy_set_header X-Forwarded-For[^\n]*\n/gm, + '' + ); + const updated = withoutForwardedFor.replace(proxyLine, (line) => { + const indent = line.match(/^[\t ]*/)?.[0] ?? ''; + return `${line}${indent}proxy_set_header X-Forwarded-For $remote_addr;\n`; + }); + return `${opening}${updated}${closing}`; + } + ); + return this.createPatchWithDiff(overridePath ?? this.filePath, fileContent, newContent); } async shouldApply(): Promise { - const { shouldApply, reason } = await super.shouldApply(); + const { shouldApply, reason } = await super.shouldApply({ checkOsVersion: false }); return { shouldApply, reason,