Skip to content

Commit a88c521

Browse files
committed
feat: implement sandbox access control for GraphQL endpoint
- Added a preHandler hook to block GET requests to the /graphql endpoint when the sandbox mode is disabled, returning a 403 error with a descriptive message. - Refactored the sandbox plugin to remove the isSandboxEnabled parameter, simplifying its usage. - Updated the ConnectSettings component to reflect changes in restart logic based on mutation response instead of computed properties.
1 parent cf70532 commit a88c521

5 files changed

Lines changed: 54 additions & 50 deletions

File tree

api/src/unraid-api/graph/graph.module.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
4646
},
4747
plugins: [
4848
createDynamicIntrospectionPlugin(isSandboxEnabled),
49-
createSandboxPlugin(isSandboxEnabled),
49+
createSandboxPlugin(),
5050
] as any[],
5151
subscriptions: {
5252
'graphql-ws': {

api/src/unraid-api/graph/resolvers/sso/oidc-config.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
336336

337337
// Include validation results in response
338338
const response: { restartRequired: boolean; values: OidcConfig; warnings?: string[] } = {
339-
restartRequired: true,
339+
restartRequired: false,
340340
values: processedConfig,
341341
};
342342

api/src/unraid-api/graph/sandbox-plugin.ts

Lines changed: 23 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -28,34 +28,23 @@ const preconditionFailed = (preconditionName: string) => {
2828
throw new HttpException(`Precondition failed: ${preconditionName} `, HttpStatus.PRECONDITION_FAILED);
2929
};
3030

31-
export const getPluginBasedOnSandbox = async (sandbox: boolean, csrfToken: string) => {
32-
if (sandbox) {
33-
const { ApolloServerPluginLandingPageLocalDefault } = await import(
34-
'@apollo/server/plugin/landingPage/default'
35-
);
36-
const plugin = ApolloServerPluginLandingPageLocalDefault({
37-
footer: false,
38-
includeCookies: true,
39-
document: initialDocument,
40-
embed: {
41-
initialState: {
42-
sharedHeaders: {
43-
'x-csrf-token': csrfToken,
44-
},
31+
export const getSandboxPlugin = async (csrfToken: string) => {
32+
const { ApolloServerPluginLandingPageLocalDefault } = await import(
33+
'@apollo/server/plugin/landingPage/default'
34+
);
35+
const plugin = ApolloServerPluginLandingPageLocalDefault({
36+
footer: false,
37+
includeCookies: true,
38+
document: initialDocument,
39+
embed: {
40+
initialState: {
41+
sharedHeaders: {
42+
'x-csrf-token': csrfToken,
4543
},
4644
},
47-
});
48-
return plugin;
49-
} else {
50-
const { ApolloServerPluginLandingPageProductionDefault } = await import(
51-
'@apollo/server/plugin/landingPage/default'
52-
);
53-
54-
const plugin = ApolloServerPluginLandingPageProductionDefault({
55-
footer: false,
56-
});
57-
return plugin;
58-
}
45+
},
46+
});
47+
return plugin;
5948
};
6049

6150
/**
@@ -72,11 +61,10 @@ export const getPluginBasedOnSandbox = async (sandbox: boolean, csrfToken: strin
7261
* - Initial document state
7362
* - Shared headers containing CSRF token
7463
*/
75-
async function renderSandboxPage(service: GraphQLServerContext, isSandboxEnabled: () => boolean) {
64+
async function renderSandboxPage(service: GraphQLServerContext) {
7665
const { getters } = await import('@app/store/index.js');
77-
const sandbox = isSandboxEnabled();
7866
const csrfToken = getters.emhttp().var.csrfToken;
79-
const plugin = await getPluginBasedOnSandbox(sandbox, csrfToken);
67+
const plugin = await getSandboxPlugin(csrfToken);
8068

8169
if (!plugin.serverWillStart) return preconditionFailed('serverWillStart');
8270
const serverListener = await plugin.serverWillStart(service);
@@ -88,15 +76,15 @@ async function renderSandboxPage(service: GraphQLServerContext, isSandboxEnabled
8876
}
8977

9078
/**
91-
* Apollo plugin to render the GraphQL Sandbox page on-demand based on current server state.
79+
* Apollo plugin to render the GraphQL Sandbox page.
9280
*
93-
* Usually, the `ApolloServerPluginLandingPageLocalDefault` plugin configures its
94-
* parameters once, during server startup. This plugin defers the configuration
95-
* and rendering to request-time instead of server startup.
81+
* Access to this page is controlled by the sandbox-access-plugin which blocks
82+
* GET requests when sandbox is disabled. This plugin only handles rendering
83+
* the sandbox UI when it's allowed through.
9684
*/
97-
export const createSandboxPlugin = (isSandboxEnabled: () => boolean): ApolloServerPlugin => ({
85+
export const createSandboxPlugin = (): ApolloServerPlugin => ({
9886
serverWillStart: async (service) =>
9987
({
100-
renderLandingPage: () => renderSandboxPage(service, isSandboxEnabled),
88+
renderLandingPage: () => renderSandboxPage(service),
10189
}) satisfies GraphQLServerListener,
10290
});

api/src/unraid-api/main.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
22
import { ValidationPipe } from '@nestjs/common';
3+
import { ConfigService } from '@nestjs/config';
34
import { NestFactory } from '@nestjs/core';
45
import { FastifyAdapter } from '@nestjs/platform-fastify/index.js';
56

@@ -75,6 +76,29 @@ export async function bootstrapNestServer(): Promise<NestFastifyApplication> {
7576
hsts: false,
7677
});
7778

79+
// Add sandbox access control hook
80+
server.addHook('preHandler', async (request, reply) => {
81+
// Only block GET requests to /graphql when sandbox is disabled
82+
if (request.method === 'GET' && request.url === '/graphql') {
83+
const configService = app.get(ConfigService);
84+
const sandboxEnabled = configService.get('api.sandbox');
85+
86+
if (!sandboxEnabled) {
87+
reply.status(403).send({
88+
errors: [
89+
{
90+
message: 'GraphQL sandbox is disabled. Enable it in the API settings.',
91+
extensions: {
92+
code: 'SANDBOX_DISABLED',
93+
},
94+
},
95+
],
96+
});
97+
return;
98+
}
99+
}
100+
});
101+
78102
// Allows all origins but still checks authentication
79103
app.enableCors({
80104
origin: true, // Allows all origins

web/components/ConnectSettings/ConnectSettings.ce.vue

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,7 @@ watch(result, () => {
3434
// unified values are namespaced (e.g., { api: { ... } })
3535
formState.value = structuredClone(result.value.settings.unified.values ?? {});
3636
});
37-
const restartRequired = computed(() => {
38-
interface SandboxValues {
39-
api?: {
40-
sandbox?: boolean;
41-
};
42-
}
43-
const currentSandbox = (settings.value?.values as SandboxValues)?.api?.sandbox;
44-
const updatedSandbox = (formState.value as SandboxValues)?.api?.sandbox;
45-
return currentSandbox !== updatedSandbox;
46-
});
37+
// Remove the computed restartRequired since we get it from the mutation response
4738
4839
/**--------------------------------------------
4940
* Update Settings Actions
@@ -57,6 +48,7 @@ const {
5748
} = useMutation(updateConnectSettings);
5849
5950
const isUpdating = ref(false);
51+
const actualRestartRequired = ref(false);
6052
6153
// prevent ui flash if loading finishes too fast
6254
watchDebounced(
@@ -70,9 +62,10 @@ watchDebounced(
7062
);
7163
7264
// show a toast when the update is done
73-
onMutateSettingsDone(() => {
65+
onMutateSettingsDone((result) => {
66+
actualRestartRequired.value = result.data?.updateSettings?.restartRequired ?? false;
7467
globalThis.toast.success('Updated API Settings', {
75-
description: restartRequired.value ? 'The API is restarting...' : undefined,
68+
description: actualRestartRequired.value ? 'The API is restarting...' : undefined,
7669
});
7770
});
7871
@@ -131,7 +124,6 @@ const onChange = ({ data }: { data: Record<string, unknown> }) => {
131124
<div class="mt-6 grid grid-cols-settings gap-y-6 items-baseline">
132125
<div class="text-sm text-end">
133126
<p v-if="isUpdating">Applying Settings...</p>
134-
<p v-else-if="restartRequired">The API will restart after settings are applied.</p>
135127
</div>
136128
<div class="col-start-2 ml-10 space-y-4">
137129
<BrandButton

0 commit comments

Comments
 (0)