Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ const deployOptions = getDeploymentEnvOptions();
| `SERVER_TRUST_PROXY` | Trust proxy headers |
| `SERVER_ORIGIN` | Server origin URL |
| `SERVER_STRICT_AUTH` | Strict authentication mode |
| `SERVER_EXPOSE_ERRORS` | Return raw internal errors to clients (local debugging only; default `false`, masked) |

### CDN/Storage

Expand Down
1 change: 1 addition & 0 deletions agentic/agentic-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const IDENTITY_HEADERS = [
*/
export const createAgenticServer = (options: AgenticServerStartOptions): express.Express => {
const app = express();
app.disable('x-powered-by');
app.use(express.json());

// When isPublic === true, strip identity headers from all incoming requests.
Expand Down
1 change: 1 addition & 0 deletions graphile/graphile-cache/src/create-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const createGraphileInstance = async (
const serv = pgl.createServ(grafserv);

const handler = express();
handler.disable('x-powered-by');
const httpServer = createServer(handler);
await serv.addTo(handler, httpServer);
await serv.ready();
Expand Down
19 changes: 19 additions & 0 deletions graphile/graphile-presigned-url-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,22 @@ const preset = {
],
};
```

### Internal vs public storage endpoint

`client` is what the server uses to talk to storage. Presigned URLs are handed to
clients, and SigV4 signs the `Host` header, so they must be signed for the host
the client will call. When storage is reached over an internal address (e.g. an
in-cluster Service), pass a second client configured with the public endpoint:

```typescript
s3: {
client: internalClient, // endpoint: http://minio.storage.svc.cluster.local:9000
presignClient: publicClient, // endpoint: https://storage.example.com
publicEndpoint: 'https://storage.example.com',
bucket: 'my-uploads',
}
```

Without `presignClient`, URLs are signed with `client`. In the Constructive server
this is `CDN_ENDPOINT` (internal) and `CDN_PUBLIC_ENDPOINT` (public).
142 changes: 142 additions & 0 deletions graphile/graphile-presigned-url-plugin/__tests__/s3-signer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* Presigned URLs are handed to clients, so they must be signed for the host the
* client calls. SigV4 signs the Host header: a URL minted for an internal
* storage host (an in-cluster Service) cannot be repointed at the public host
* afterwards — the signature only validates for the host it was signed with.
*/

import { S3Client } from '@aws-sdk/client-s3';
import { createHash, createHmac } from 'crypto';

import { generatePresignedGetUrl, generatePresignedPutUrl } from '../src/s3-signer';
import type { S3Config } from '../src/types';

const INTERNAL = 'http://rustfs.constructive-infra.svc.cluster.local:9000';
const PUBLIC = 'https://storage.example.com';
const REGION = 'us-east-1';
const ACCESS_KEY = 'AKIDEXAMPLE';
const SECRET_KEY = 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY';
const NOW = new Date('2026-01-01T00:00:00.000Z');

function client(endpoint: string): S3Client {
return new S3Client({
region: REGION,
endpoint,
forcePathStyle: true,
credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY },
});
}

const internalOnly: S3Config = {
client: client(INTERNAL),
bucket: 'tenant-bucket',
endpoint: INTERNAL,
region: REGION,
forcePathStyle: true,
};

const withPublicEndpoint: S3Config = {
...internalOnly,
presignClient: client(PUBLIC),
publicEndpoint: PUBLIC,
};

const encode = (value: string) =>
encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
const sha256 = (value: string) => createHash('sha256').update(value).digest('hex');
const hmac = (key: Buffer | string, value: string) => createHmac('sha256', key).update(value).digest();

/**
* Independent SigV4 query-signature check, as the storage server performs it:
* recompute the signature for the request a client sends to `url` (whose Host
* is `url.host`) and compare it with the one in the URL.
*/
function signatureValidates(method: string, url: string, headers: Record<string, string> = {}): boolean {
const parsed = new URL(url);
const params = [...parsed.searchParams.entries()];
const signature = parsed.searchParams.get('X-Amz-Signature');
const amzDate = parsed.searchParams.get('X-Amz-Date')!;
const [, date, region, service] = parsed.searchParams.get('X-Amz-Credential')!.split('/');
const signedHeaders = parsed.searchParams.get('X-Amz-SignedHeaders')!;

const requestHeaders: Record<string, string> = { host: parsed.host, ...headers };
const canonicalQuery = params
.filter(([name]) => name !== 'X-Amz-Signature')
.map(([name, value]) => `${encode(name)}=${encode(value)}`)
.sort()
.join('&');
const canonicalHeaders = signedHeaders
.split(';')
.map((name) => `${name}:${requestHeaders[name]}\n`)
.join('');
const canonicalRequest = [
method, parsed.pathname, canonicalQuery, canonicalHeaders, signedHeaders, 'UNSIGNED-PAYLOAD',
].join('\n');

const scope = `${date}/${region}/${service}/aws4_request`;
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256(canonicalRequest)].join('\n');
const signingKey = hmac(hmac(hmac(hmac(`AWS4${SECRET_KEY}`, date), region), service), 'aws4_request');
return hmac(signingKey, stringToSign).toString('hex') === signature;
}

/** The same URL pointed at another host — what a post-signing host rewrite produces. */
const rehost = (url: string, endpoint: string) => {
const parsed = new URL(url);
const target = new URL(endpoint);
parsed.protocol = target.protocol;
parsed.host = target.host;
return parsed.toString();
};

beforeAll(() => {
jest.useFakeTimers({ now: NOW, advanceTimers: true });
});

afterAll(() => {
jest.useRealTimers();
});

describe('generatePresignedPutUrl', () => {
const putHeaders = { 'content-length': '11', 'content-type': 'text/plain' };

it('signs for the internal endpoint when no public endpoint is configured', async () => {
const url = await generatePresignedPutUrl(internalOnly, 'abc', 'text/plain', 11, 900);

expect(new URL(url).origin).toBe(INTERNAL);
expect(new URL(url).pathname).toBe('/tenant-bucket/abc');
expect(new URL(url).searchParams.get('X-Amz-Date')).toBe('20260101T000000Z');
expect(signatureValidates('PUT', url, putHeaders)).toBe(true);
});

it('signs for the public endpoint when one is configured', async () => {
const url = await generatePresignedPutUrl(withPublicEndpoint, 'abc', 'text/plain', 11, 900);

expect(new URL(url).origin).toBe(PUBLIC);
expect(new URL(url).pathname).toBe('/tenant-bucket/abc');
expect(signatureValidates('PUT', url, putHeaders)).toBe(true);
});

it('a URL signed for the internal host does not validate at the public host', async () => {
const url = await generatePresignedPutUrl(internalOnly, 'abc', 'text/plain', 11, 900);

expect(signatureValidates('PUT', rehost(url, PUBLIC), putHeaders)).toBe(false);
});
});

describe('generatePresignedGetUrl', () => {
it('signs for the internal endpoint when no public endpoint is configured', async () => {
const url = await generatePresignedGetUrl(internalOnly, 'abc', 3600, 'report.pdf');

expect(new URL(url).origin).toBe(INTERNAL);
expect(signatureValidates('GET', url)).toBe(true);
});

it('signs for the public endpoint when one is configured', async () => {
const url = await generatePresignedGetUrl(withPublicEndpoint, 'abc', 3600, 'report.pdf');

expect(new URL(url).origin).toBe(PUBLIC);
expect(new URL(url).searchParams.get('response-content-disposition')).toBe('attachment; filename="report.pdf"');
expect(signatureValidates('GET', url)).toBe(true);
expect(signatureValidates('GET', rehost(url, INTERNAL))).toBe(false);
});
});
18 changes: 14 additions & 4 deletions graphile/graphile-presigned-url-plugin/src/s3-signer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ import type { S3Config } from './types';

const log = new Logger('graphile-presigned-url:s3');

/** Presigned URLs go to clients, so they are signed for the client-reachable endpoint. */
function presignTarget(s3Config: S3Config) {
return {
client: s3Config.presignClient ?? s3Config.client,
endpoint: s3Config.presignClient ? s3Config.publicEndpoint : s3Config.endpoint,
};
}

/**
* Generate a presigned PUT URL for uploading a file to S3.
*
Expand Down Expand Up @@ -41,13 +49,14 @@ export async function generatePresignedPutUrl(
ContentLength: contentLength,
});

const { client, endpoint } = presignTarget(s3Config);
let url: string;
try {
url = await getSignedUrl(s3Config.client as any, command, { expiresIn });
url = await getSignedUrl(client as any, command, { expiresIn });
} catch (err) {
throw s3FailureError(
'PRESIGN_PUT_FAILED',
{ endpoint: s3Config.endpoint, bucket: s3Config.bucket, key, contentType },
{ endpoint, bucket: s3Config.bucket, key, contentType },
err,
);
}
Expand Down Expand Up @@ -84,11 +93,12 @@ export async function generatePresignedGetUrl(
}

const command = new GetObjectCommand(params as any);
const { client, endpoint } = presignTarget(s3Config);
let url: string;
try {
url = await getSignedUrl(s3Config.client as any, command, { expiresIn });
url = await getSignedUrl(client as any, command, { expiresIn });
} catch (err) {
throw s3FailureError('PRESIGN_GET_FAILED', { endpoint: s3Config.endpoint, bucket: s3Config.bucket, key }, err);
throw s3FailureError('PRESIGN_GET_FAILED', { endpoint, bucket: s3Config.bucket, key }, err);
}
log.debug(`Generated presigned GET URL for key=${key}, expires=${expiresIn}s`);
return url;
Expand Down
8 changes: 8 additions & 0 deletions graphile/graphile-presigned-url-plugin/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,14 @@ export interface S3Config {
bucket: string;
/** S3 endpoint URL (for RustFS, MinIO, or custom S3) */
endpoint?: string;
/**
* Client used only to sign presigned URLs handed to clients, configured with
* the client-reachable `publicEndpoint`. SigV4 signs the Host header, so a URL
* must be signed for the host the client will call. Defaults to `client`.
*/
presignClient?: S3Client;
/** Endpoint `presignClient` signs for */
publicEndpoint?: string;
/** S3 region */
region?: string;
/** Whether to use path-style URLs (required for path-style S3-compatible storage) */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface CdnOptions {
awsAccessKey?: string;
awsSecretKey?: string;
endpoint?: string;
publicEndpoint?: string;
publicUrlPrefix?: string;
}

Expand All @@ -18,14 +19,13 @@ async function loadResolverModule(cdn: CdnOptions | undefined) {
jest.doMock('@constructive-io/graphql-env', () => ({
getEnvOptions: jest.fn(() => ({ cdn })),
}));
jest.doMock('@constructive-io/s3-utils', () => ({
createS3Client: jest.fn(() => ({ send: jest.fn() })),
}));
const createS3Client = jest.fn((config: { endpoint?: string }) => ({ endpoint: config.endpoint }));
jest.doMock('@constructive-io/s3-utils', () => ({ createS3Client }));
jest.doMock('@pgpmjs/logger', () => ({
Logger: jest.fn().mockImplementation(() => ({ info: jest.fn() })),
}));

return import('../src/presigned-url-resolver');
return { ...(await import('../src/presigned-url-resolver')), createS3Client };
}

const BASE_CDN: CdnOptions = {
Expand All @@ -50,6 +50,30 @@ describe('getPresignedUrlS3Config', () => {
}));
});

it('signs presigned URLs with the connection client when no public endpoint is set', async () => {
const { getPresignedUrlS3Config, createS3Client } = await loadResolverModule(BASE_CDN);
const config = getPresignedUrlS3Config();

expect(config.client).toEqual({ endpoint: 'http://localhost:9000' });
expect(config.presignClient).toBeUndefined();
expect(config.publicEndpoint).toBeUndefined();
expect(createS3Client).toHaveBeenCalledTimes(1);
});

it('talks to storage over the endpoint and signs presigned URLs for the public endpoint', async () => {
const { getPresignedUrlS3Config } = await loadResolverModule({
...BASE_CDN,
endpoint: 'http://rustfs.constructive-infra.svc.cluster.local:9000',
publicEndpoint: 'https://storage.example.com',
});
const config = getPresignedUrlS3Config();

expect(config.client).toEqual({ endpoint: 'http://rustfs.constructive-infra.svc.cluster.local:9000' });
expect(config.endpoint).toBe('http://rustfs.constructive-infra.svc.cluster.local:9000');
expect(config.presignClient).toEqual({ endpoint: 'https://storage.example.com' });
expect(config.publicEndpoint).toBe('https://storage.example.com');
});

it('caches the initialized S3 configuration', async () => {
const { getPresignedUrlS3Config } = await loadResolverModule(BASE_CDN);

Expand Down
16 changes: 11 additions & 5 deletions graphile/graphile-settings/src/presigned-url-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
* initializes an S3Client on first use.
*
* Follows the same lazy-init pattern as upload-resolver.ts.
*
* `cdn.endpoint` (CDN_ENDPOINT) is the host the server talks to; presigned URLs
* are signed for `cdn.publicEndpoint` (CDN_PUBLIC_ENDPOINT) when it is set, so a
* cluster-internal storage host never reaches a client.
*/

import { getEnvOptions } from '@constructive-io/graphql-env';
Expand Down Expand Up @@ -42,7 +46,7 @@ export function getPresignedUrlS3Config(): S3Config {
);
}

const { bucketName, awsRegion, awsAccessKey, awsSecretKey, endpoint, publicUrlPrefix } = cdn;
const { bucketName, awsRegion, awsAccessKey, awsSecretKey, endpoint, publicEndpoint, publicUrlPrefix } = cdn;

if (!awsAccessKey || !awsSecretKey) {
throw new Error(
Expand All @@ -59,23 +63,25 @@ export function getPresignedUrlS3Config(): S3Config {
}

log.info(
`[presigned-url-resolver] Initializing: bucket=${bucketName} endpoint=${endpoint}`,
`[presigned-url-resolver] Initializing: bucket=${bucketName} endpoint=${endpoint} ` +
`publicEndpoint=${publicEndpoint ?? endpoint}`,
);

const client = createS3Client({
const connect = (url: string | undefined) => createS3Client({
provider: (cdn.provider || 'minio') as any,
region: awsRegion,
accessKeyId: awsAccessKey,
secretAccessKey: awsSecretKey,
...(endpoint ? { endpoint } : {}),
...(url ? { endpoint: url } : {}),
});

s3Config = {
client,
client: connect(endpoint),
bucket: bucketName,
region: awsRegion,
publicUrlPrefix,
...(endpoint ? { endpoint, forcePathStyle: true } : {}),
...(publicEndpoint ? { presignClient: connect(publicEndpoint), publicEndpoint } : {}),
};

return s3Config;
Expand Down
4 changes: 2 additions & 2 deletions graphql/dev-server/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { getEnvOptions } from '@constructive-io/graphql-env';
import type { ConstructiveOptions } from '@constructive-io/graphql-types';
import { Logger } from '@pgpmjs/logger';
import { cors, healthz, poweredBy } from '@pgpmjs/server-utils';
import { cors, healthz } from '@pgpmjs/server-utils';
import express from 'express';
import { createGraphileInstance, type GraphileCacheEntry } from 'graphile-cache';
import { getPgPool } from 'pg-cache';
Expand Down Expand Up @@ -47,9 +47,9 @@ export const createDevServer = async (
});

const app = express();
app.disable('x-powered-by');
healthz(app);
cors(app, serverOpts.origin ?? opts.server?.origin);
app.use(poweredBy('constructive'));
app.use((req, res, next) => instance.handler(req, res, next));

const httpServer = await new Promise<import('http').Server>((resolve, reject) => {
Expand Down
1 change: 1 addition & 0 deletions graphql/env/__tests__/__snapshots__/merge.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and
"user": "env-user",
},
"server": {
"exposeErrors": false,
"host": "localhost",
"port": 5000,
"strictAuth": false,
Expand Down
Loading
Loading