Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions graphql/env/__tests__/__snapshots__/merge.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and
],
"roleName": "env_role",
},
"captcha": {
"recaptchaSecretKey": "test-recaptcha-secret",
},
"cdn": {
"awsAccessKey": "minioadmin",
"awsRegion": "us-east-1",
Expand Down Expand Up @@ -120,5 +123,8 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and
"port": 587,
"secure": false,
},
"upload": {
"maxFileSize": 123456,
},
}
`;
6 changes: 5 additions & 1 deletion graphql/env/__tests__/merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ describe('getEnvOptions', () => {
API_META_SCHEMAS: 'env_meta1,env_meta2',
API_ANON_ROLE: 'env_anon',
API_ROLE_NAME: 'env_role',
API_DEFAULT_DATABASE_ID: 'env_db'
API_DEFAULT_DATABASE_ID: 'env_db',
RECAPTCHA_SECRET_KEY: 'test-recaptcha-secret',
MAX_UPLOAD_FILE_SIZE: '123456'
};

const result = getEnvOptions(
Expand Down Expand Up @@ -82,6 +84,8 @@ describe('getEnvOptions', () => {
);

expect(result).toMatchSnapshot();
expect(result.captcha?.recaptchaSecretKey).toBe('test-recaptcha-secret');
expect(result.upload?.maxFileSize).toBe(123456);
});

it('replaces graphql array fields with later values (overrides win)', () => {
Expand Down
68 changes: 68 additions & 0 deletions graphql/server/src/middleware/__tests__/captcha.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { NextFunction, Request, Response } from 'express';

import { createCaptchaMiddleware } from '../captcha';

const originalFetch = global.fetch;
const originalRecaptchaSecretKey = process.env.RECAPTCHA_SECRET_KEY;

const createReq = (overrides: Partial<Request> = {}): Request => ({
api: {
authSettings: {
enableCaptcha: true,
},
},
body: {
operationName: 'signUp',
},
get: jest.fn((name: string) => (name.toLowerCase() === 'x-captcha-token' ? 'token-123' : undefined)),
...overrides,
} as unknown as Request);

const createRes = (): Response => ({
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
} as unknown as Response);

describe('createCaptchaMiddleware', () => {
afterEach(() => {
global.fetch = originalFetch;
if (originalRecaptchaSecretKey === undefined) {
delete process.env.RECAPTCHA_SECRET_KEY;
} else {
process.env.RECAPTCHA_SECRET_KEY = originalRecaptchaSecretKey;
}
jest.restoreAllMocks();
});

it('does not read RECAPTCHA_SECRET_KEY directly from process.env', async () => {
process.env.RECAPTCHA_SECRET_KEY = 'env-secret';
const fetchMock = jest.fn();
global.fetch = fetchMock as unknown as typeof fetch;

const next = jest.fn() as NextFunction;
await createCaptchaMiddleware()(createReq(), createRes(), next);

expect(fetchMock).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledTimes(1);
});

it('uses the configured reCAPTCHA secret key when verifying a token', async () => {
const fetchMock = jest.fn().mockResolvedValue({
json: async () => ({ success: true }),
});
global.fetch = fetchMock as unknown as typeof fetch;

const next = jest.fn() as NextFunction;
await createCaptchaMiddleware({ recaptchaSecretKey: 'configured-secret' })(
createReq(),
createRes(),
next,
);

expect(fetchMock).toHaveBeenCalledTimes(1);
const body = fetchMock.mock.calls[0][1]?.body as URLSearchParams;
expect(body.get('secret')).toBe('configured-secret');
expect(body.get('response')).toBe('token-123');
expect(next).toHaveBeenCalledTimes(1);
});
});
16 changes: 11 additions & 5 deletions graphql/server/src/middleware/captcha.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ const CAPTCHA_PROTECTED_OPERATIONS = new Set([
'requestPasswordReset',
]);

export interface CaptchaMiddlewareOptions {
recaptchaSecretKey?: string;
}

interface RecaptchaResponse {
success: boolean;
'error-codes'?: string[];
Expand Down Expand Up @@ -70,15 +74,17 @@ const verifyToken = async (token: string, secretKey: string): Promise<boolean> =
*
* When `enable_captcha` is true in app_auth_settings, this middleware checks
* the X-Captcha-Token header on protected mutations (sign-up, password reset).
* The secret key is read from the RECAPTCHA_SECRET_KEY environment variable
* The secret key is passed from server configuration
* (the public site key is stored in app_auth_settings for the frontend).
*
* Skips verification when:
* - CAPTCHA is not enabled in auth settings
* - The request is not a protected mutation
* - No secret key is configured server-side
*/
export const createCaptchaMiddleware = (): RequestHandler => {
export const createCaptchaMiddleware = (
opts: CaptchaMiddlewareOptions = {},
): RequestHandler => {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const authSettings = req.api?.authSettings;

Expand All @@ -93,10 +99,10 @@ export const createCaptchaMiddleware = (): RequestHandler => {
return next();
}

// Secret key must be set server-side (env var, not stored in DB for security)
const secretKey = process.env.RECAPTCHA_SECRET_KEY;
// Secret key must be set server-side (config/env, not stored in DB for security)
const secretKey = opts.recaptchaSecretKey;
if (!secretKey) {
log.warn('[captcha] enable_captcha is true but RECAPTCHA_SECRET_KEY env var is not set; skipping verification');
log.warn('[captcha] enable_captcha is true but no reCAPTCHA secret key is configured; skipping verification');
return next();
}

Expand Down
7 changes: 5 additions & 2 deletions graphql/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,9 @@ class Server {
app.use(poweredBy('constructive'));
app.use(cookieParser());
app.use(cors(fallbackOrigin));
const uploadMaxFileSize = effectiveOpts.upload?.maxFileSize ?? 10 * 1024 * 1024;
app.use('/graphql', graphqlUpload.graphqlUploadExpress({
maxFileSize: 10 * 1024 * 1024, // 10 MB
maxFileSize: uploadMaxFileSize,
maxFiles: 10,
}));

Expand All @@ -165,7 +166,9 @@ class Server {
app.use(api);
app.use(authenticate);
app.use(createContextMiddleware({ pg: effectiveOpts.pg }));
app.use(createCaptchaMiddleware());
app.use(createCaptchaMiddleware({
recaptchaSecretKey: effectiveOpts.captcha?.recaptchaSecretKey,
}));

// CSRF protection for cookie-authenticated requests
// Skip CSRF for Bearer token auth (not vulnerable to CSRF) and anonymous requests
Expand Down
6 changes: 6 additions & 0 deletions graphql/types/src/constructive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
DeploymentOptions,
ServerOptions,
CDNOptions,
CaptchaOptions,
UploadOptions,
MigrationOptions,
JobsConfig
} from '@pgpmjs/types';
Expand Down Expand Up @@ -51,6 +53,10 @@ export interface ConstructiveOptions extends PgpmOptions, ConstructiveGraphQLOpt
api?: ApiOptions;
/** CDN and file storage configuration */
cdn?: CDNOptions;
/** CAPTCHA verification configuration */
captcha?: CaptchaOptions;
/** GraphQL upload configuration */
upload?: UploadOptions;
/** Module deployment configuration */
deployment?: DeploymentOptions;
/** Migration and code generation options */
Expand Down
12 changes: 12 additions & 0 deletions pgpm/env/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,15 @@ import { getEnvOptions } from '@pgpmjs/env';

const options = getEnvOptions(overrides, cwd);
```

## Environment Variables

Selected server/runtime variables parsed by this package:

| Variable | Option |
| --- | --- |
| `OAUTH_STATE_SECRET` | `oauth.stateSecret` |
| `RECAPTCHA_SECRET_KEY` | `captcha.recaptchaSecretKey` |
| `MAX_UPLOAD_FILE_SIZE` | `upload.maxFileSize` |

`RECAPTCHA_SECRET_KEY` is the server-side secret used to verify CAPTCHA tokens. `MAX_UPLOAD_FILE_SIZE` is parsed as bytes and controls GraphQL multipart upload limits.
6 changes: 6 additions & 0 deletions pgpm/env/__tests__/__snapshots__/merge.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

exports[`getEnvOptions merges defaults, config, env, and overrides 1`] = `
{
"captcha": {
"recaptchaSecretKey": "test-recaptcha-secret",
},
"cdn": {
"awsAccessKey": "minioadmin",
"awsRegion": "us-east-1",
Expand Down Expand Up @@ -99,5 +102,8 @@ exports[`getEnvOptions merges defaults, config, env, and overrides 1`] = `
"port": 587,
"secure": false,
},
"upload": {
"maxFileSize": 123456,
},
}
`;
14 changes: 13 additions & 1 deletion pgpm/env/__tests__/merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,9 @@ describe('getEnvOptions', () => {
PORT: '7777',
DEPLOYMENT_FAST: 'false',
JOBS_SUPPORT_ANY: 'false',
JOBS_SUPPORTED: 'alpha,beta'
JOBS_SUPPORTED: 'alpha,beta',
RECAPTCHA_SECRET_KEY: 'test-recaptcha-secret',
MAX_UPLOAD_FILE_SIZE: '123456'
};

const result = getEnvOptions(
Expand All @@ -178,6 +180,8 @@ describe('getEnvOptions', () => {
);

expect(result).toMatchSnapshot();
expect(result.captcha?.recaptchaSecretKey).toBe('test-recaptcha-secret');
expect(result.upload?.maxFileSize).toBe(123456);
});

it('replaces array fields with later values (overrides win)', () => {
Expand Down Expand Up @@ -225,4 +229,12 @@ describe('getEnvOptions', () => {
expect(result.jobs?.scheduler?.supported).toEqual(['gamma', 'zeta']);
expect(result.packages).toEqual(['testing/*', 'extensions/*']);
});

it('ignores invalid MAX_UPLOAD_FILE_SIZE values', () => {
const result = getEnvOptions({}, process.cwd(), {
MAX_UPLOAD_FILE_SIZE: 'not-a-number'
});

expect(result.upload?.maxFileSize).toBe(pgpmDefaults.upload?.maxFileSize);
});
});
10 changes: 10 additions & 0 deletions pgpm/env/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export const getEnvVars = (env: NodeJS.ProcessEnv = process.env): PgpmOptions =>
CDN_ENDPOINT,
CDN_PUBLIC_URL_PREFIX,

RECAPTCHA_SECRET_KEY,
MAX_UPLOAD_FILE_SIZE,

DEPLOYMENT_USE_TX,
DEPLOYMENT_FAST,
DEPLOYMENT_USE_PLAN,
Expand Down Expand Up @@ -100,6 +103,7 @@ export const getEnvVars = (env: NodeJS.ProcessEnv = process.env): PgpmOptions =>
SMTP_LOGGER,
SMTP_DEBUG
} = env;
const maxUploadFileSize = parseEnvNumber(MAX_UPLOAD_FILE_SIZE);

return {
db: {
Expand Down Expand Up @@ -155,6 +159,12 @@ export const getEnvVars = (env: NodeJS.ProcessEnv = process.env): PgpmOptions =>
...(CDN_ENDPOINT && { endpoint: CDN_ENDPOINT }),
...(CDN_PUBLIC_URL_PREFIX && { publicUrlPrefix: CDN_PUBLIC_URL_PREFIX }),
},
captcha: {
...(RECAPTCHA_SECRET_KEY && { recaptchaSecretKey: RECAPTCHA_SECRET_KEY }),
},
upload: {
...(maxUploadFileSize !== undefined && { maxFileSize: maxUploadFileSize }),
},
deployment: {
...(DEPLOYMENT_USE_TX && { useTx: parseEnvBoolean(DEPLOYMENT_USE_TX) }),
...(DEPLOYMENT_FAST && { fast: parseEnvBoolean(DEPLOYMENT_FAST) }),
Expand Down
24 changes: 24 additions & 0 deletions pgpm/types/src/pgpm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,22 @@ export interface CDNOptions {
publicUrlPrefix?: string;
}

/**
* CAPTCHA verification configuration
*/
export interface CaptchaOptions {
/** Secret key used by the server to verify reCAPTCHA tokens */
recaptchaSecretKey?: string;
}

/**
* GraphQL upload configuration
*/
export interface UploadOptions {
/** Maximum accepted GraphQL upload file size in bytes */
maxFileSize?: number;
}

/**
* SMTP email configuration options
*/
Expand Down Expand Up @@ -245,6 +261,10 @@ export interface PgpmOptions {
server?: ServerOptions;
/** CDN and file storage configuration */
cdn?: CDNOptions;
/** CAPTCHA verification configuration */
captcha?: CaptchaOptions;
/** GraphQL upload configuration */
upload?: UploadOptions;
/** Module deployment configuration */
deployment?: DeploymentOptions;
/** Migration and code generation options */
Expand Down Expand Up @@ -306,6 +326,10 @@ export const pgpmDefaults: PgpmOptions = {
endpoint: 'http://localhost:9000',
publicUrlPrefix: 'http://localhost:9000'
},
captcha: {},
upload: {
maxFileSize: 10 * 1024 * 1024
},
deployment: {
useTx: true,
fast: false,
Expand Down