diff --git a/.env.example b/.env.example index 726f74601a..422511c876 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,13 @@ HTTP_PORT=80 # HTTPS_PORT=443 # uncomment after you enable TLS in caddy/Caddyfile +# ── Public domain & protocol ------------------------------------------ +# Domain and protocol used by Puter and its subdomains (api, app, site, +# dev, host). Also used by s3-init to configure S3/RustFS bucket CORS +# for browser presigned uploads. Set PUTER_PROTOCOL=https when TLS is enabled. +PUTER_DOMAIN=puter.localhost +PUTER_PROTOCOL=http + # ── MariaDB ------------------------------------------------------------ MARIADB_ROOT_PASSWORD=replace-with-strong-password MARIADB_DATABASE=puter diff --git a/doc/self-hosting.md b/doc/self-hosting.md index d470174e11..410cecea09 100644 --- a/doc/self-hosting.md +++ b/doc/self-hosting.md @@ -26,7 +26,7 @@ Generates secrets, writes `.env` + `puter/config/config.json`, downloads `docker | `puter-valkey` | `valkey/valkey:8-alpine` | Redis-compatible cache + rate-limiter | | `puter-dynamo` | `amazon/dynamodb-local` | KV store — table auto-created on first boot | | `puter-s3` | `rustfs/rustfs` | S3-compatible object storage (MinIO drop-in noted in file) | -| `puter-s3-init` | `amazon/aws-cli` | One-shot — creates the bucket on first boot, then exits | +| `puter-s3-init` | `amazon/aws-cli` | One-shot — creates the bucket and configures CORS, then exits | Optional services (compose profile `ai`, opt-in): @@ -54,6 +54,9 @@ cat > .env <`). Direct browser uploads to presigned S3 URLs require CORS preflight (`OPTIONS`) approval from RustFS; without bucket CORS configuration, the browser blocks the upload. `s3-init` automatically configures CORS origins for `${PUTER_PROTOCOL}://${PUTER_DOMAIN}` and its required subdomains. For HTTPS deployments, set: + ```bash + PUTER_DOMAIN=example.com + PUTER_PROTOCOL=https + ``` + This ensures CORS rules allow `https://...` origins rather than `http://...`. The `s3-init` service is idempotent: if the bucket already exists (e.g. upgrades or restarts), it detects the bucket and applies/updates the CORS policy rather than exiting early. - `jwt_secret_v2` — the HMAC secret Puter signs and verifies auth tokens with (`kid: 'v2'` JWT header). The pre-v2 token format is retired: a token signed with the old `jwt_secret` no longer verifies, and holders are asked to sign in again. If you are upgrading from a release that had `jwt_secret`, drop it from your config — it is ignored. - `env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start:gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css` `` tag instead of waiting on a manifest that doesn't exist. - `database.migrationPaths` — Puter applies the bundled MySQL/MariaDB schema on boot. The migration files are idempotent, so it is safe to leave this configured across restarts. @@ -193,7 +202,7 @@ Drop the resulting `fullchain.pem` and `privkey.pem` into `./puter/tls/`. 1. Open [caddy/Caddyfile](../caddy/Caddyfile) and uncomment the `# :443 { … }` block at the bottom. 2. (Optional but recommended) Replace the plain `:80 { import puter_routes }` block with the `redir` version shown alongside it, to force HTTPS everywhere. 3. In [docker-compose.yml](../docker-compose.yml), uncomment the `443:443` port mapping under the `caddy` service. -4. In `.env`, uncomment `HTTPS_PORT=443`. +4. In `.env`, uncomment `HTTPS_PORT=443` and set `PUTER_PROTOCOL=https` (so `s3-init` generates `https://...` CORS origins). 5. In `config.json`, switch: ```json { "protocol": "https", "pub_port": 443 } diff --git a/docker-compose.yml b/docker-compose.yml index 344555bfbf..be65d84249 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -139,8 +139,8 @@ services: start_period: 5s s3-init: - # One-shot container that creates the `puter-local` bucket on first - # boot. Exits 0 once the bucket exists; stays exited 0 thereafter. + # One-shot container that creates the `puter-local` bucket and applies + # the browser CORS policy. Both operations are idempotent. image: amazon/aws-cli:latest container_name: puter-s3-init depends_on: @@ -150,11 +150,13 @@ services: AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:-puter} AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY:-puter-secret-change-me} AWS_DEFAULT_REGION: us-east-1 + PUTER_DOMAIN: ${PUTER_DOMAIN:-puter.localhost} + PUTER_PROTOCOL: ${PUTER_PROTOCOL:-http} entrypoint: - /bin/sh - -c - | - set -e + set -eu endpoint=http://s3:9000 bucket=${S3_BUCKET:-puter-local} if aws --endpoint-url "$$endpoint" s3api head-bucket --bucket "$$bucket" 2>/dev/null; then @@ -163,6 +165,30 @@ services: echo "creating bucket $$bucket" aws --endpoint-url "$$endpoint" s3 mb "s3://$$bucket" fi + cat > /tmp/cors.json < /tmp/cors.json <\s*\/tmp\/cors\.json\s*<<\s*EOF\s*\n([\s\S]*?)\n\s*EOF/); + assert.ok(match, 'docker-compose.yml must contain cat > /tmp/cors.json < shell + const rendered = template + .replace(/\$\$\{PUTER_PROTOCOL\}|\$\{PUTER_PROTOCOL\}/g, protocol) + .replace(/\$\$\{PUTER_DOMAIN\}|\$\{PUTER_DOMAIN\}/g, domain); + + const corsConfig = JSON.parse(rendered); + return corsConfig; +} + +/** + * Simulates a browser CORS preflight check against S3 CORS rules. + */ +function evaluatePreflight(corsRule, { origin, method, headers = [] }) { + // 1. Origin match + const originAllowed = corsRule.AllowedOrigins.includes(origin) || corsRule.AllowedOrigins.includes('*'); + if (!originAllowed) { + return { allowed: false, reason: 'Origin not allowed' }; + } + + // 2. Method match + const methodAllowed = corsRule.AllowedMethods.includes(method.toUpperCase()); + if (!methodAllowed) { + return { allowed: false, reason: 'Method not allowed' }; + } + + // 3. Headers match + const headersAllowed = corsRule.AllowedHeaders.includes('*') || + headers.every(h => corsRule.AllowedHeaders.map(x => x.toLowerCase()).includes(h.toLowerCase())); + if (!headersAllowed) { + return { allowed: false, reason: 'Headers not allowed' }; + } + + return { + allowed: true, + allowOrigin: origin, + allowMethods: corsRule.AllowedMethods, + allowHeaders: corsRule.AllowedHeaders, + exposeHeaders: corsRule.ExposeHeaders || [], + maxAge: corsRule.MaxAgeSeconds + }; +} + +describe('Self-Hosted S3/RustFS CORS Configuration (#3379)', () => { + + describe('1. Default HTTP Domain Origin Generation', () => { + const corsConfig = extractAndRenderCorsJson('http', 'puter.localhost'); + const rule = corsConfig.CORSRules[0]; + + it('generates the expected set of allowed origins', () => { + const expectedOrigins = [ + 'http://puter.localhost', + 'http://api.puter.localhost', + 'http://app.puter.localhost', + 'http://site.puter.localhost', + 'http://dev.puter.localhost', + 'http://host.puter.localhost', + ]; + + assert.deepEqual(rule.AllowedOrigins, expectedOrigins); + }); + + it('does NOT contain wildcard origin (*)', () => { + assert.ok(!rule.AllowedOrigins.includes('*'), 'AllowedOrigins must not contain wildcard *'); + }); + + it('does NOT allow unauthorized external origins', () => { + assert.ok(!rule.AllowedOrigins.includes('https://evil.example.com')); + assert.ok(!rule.AllowedOrigins.includes('http://attacker.localhost')); + assert.ok(!rule.AllowedOrigins.includes('http://localhost:3000')); + }); + }); + + describe('2. Custom HTTPS Domain Origin Generation', () => { + const corsConfig = extractAndRenderCorsJson('https', 'example.com'); + const rule = corsConfig.CORSRules[0]; + + it('generates HTTPS origins for custom domain and subdomains', () => { + const expectedOrigins = [ + 'https://example.com', + 'https://api.example.com', + 'https://app.example.com', + 'https://site.example.com', + 'https://dev.example.com', + 'https://host.example.com', + ]; + + assert.deepEqual(rule.AllowedOrigins, expectedOrigins); + }); + + it('does NOT include plain http:// origins when protocol is https', () => { + const hasHttp = rule.AllowedOrigins.some(origin => origin.startsWith('http://')); + assert.equal(hasHttp, false, 'HTTPS configuration must not contain http:// origins'); + }); + }); + + describe('3. Allowed Methods, Headers, and ExposeHeaders', () => { + const corsConfig = extractAndRenderCorsJson('http', 'puter.localhost'); + const rule = corsConfig.CORSRules[0]; + + it('includes all standard S3 methods including PUT for presigned uploads', () => { + assert.ok(rule.AllowedMethods.includes('PUT'), 'AllowedMethods must include PUT'); + assert.ok(rule.AllowedMethods.includes('GET'), 'AllowedMethods must include GET'); + assert.ok(rule.AllowedMethods.includes('HEAD'), 'AllowedMethods must include HEAD'); + assert.ok(rule.AllowedMethods.includes('POST'), 'AllowedMethods must include POST'); + assert.ok(rule.AllowedMethods.includes('DELETE'), 'AllowedMethods must include DELETE'); + }); + + it('allows wildcard headers [*] required for presigned S3 uploads', () => { + assert.deepEqual(rule.AllowedHeaders, ['*']); + }); + + it('exposes critical response headers (ETag, x-amz-request-id)', () => { + assert.ok(rule.ExposeHeaders.includes('ETag'), 'ExposeHeaders must include ETag'); + assert.ok(rule.ExposeHeaders.includes('x-amz-request-id'), 'ExposeHeaders must include x-amz-request-id'); + }); + + it('sets MaxAgeSeconds to 3600 for optimal preflight caching', () => { + assert.equal(rule.MaxAgeSeconds, 3600); + }); + }); + + describe('4. Simulated Browser OPTIONS Preflight', () => { + const corsConfig = extractAndRenderCorsJson('http', 'puter.localhost'); + const rule = corsConfig.CORSRules[0]; + + it('succeeds for valid frontend origin requesting PUT with content-type', () => { + const preflight = evaluatePreflight(rule, { + origin: 'http://puter.localhost', + method: 'PUT', + headers: ['content-type'] + }); + + assert.equal(preflight.allowed, true); + assert.equal(preflight.allowOrigin, 'http://puter.localhost'); + assert.ok(preflight.allowMethods.includes('PUT')); + assert.ok(preflight.exposeHeaders.includes('ETag')); + }); + + it('succeeds for subdomain origin requesting PUT with custom headers', () => { + const preflight = evaluatePreflight(rule, { + origin: 'http://app.puter.localhost', + method: 'PUT', + headers: ['content-type', 'x-amz-meta-custom'] + }); + + assert.equal(preflight.allowed, true); + assert.equal(preflight.allowOrigin, 'http://app.puter.localhost'); + }); + + it('rejects unauthorized origin in preflight', () => { + const preflight = evaluatePreflight(rule, { + origin: 'https://evil.example.com', + method: 'PUT', + headers: ['content-type'] + }); + + assert.equal(preflight.allowed, false); + assert.equal(preflight.reason, 'Origin not allowed'); + }); + + it('rejects unauthorized HTTP method in preflight', () => { + const preflight = evaluatePreflight(rule, { + origin: 'http://puter.localhost', + method: 'PATCH', + headers: ['content-type'] + }); + + assert.equal(preflight.allowed, false); + assert.equal(preflight.reason, 'Method not allowed'); + }); + }); + + describe('5. s3-init Script Idempotency', () => { + const composeContent = fs.readFileSync(COMPOSE_PATH, 'utf8'); + + it('passes PUTER_DOMAIN and PUTER_PROTOCOL environment variables to s3-init', () => { + assert.match(composeContent, /PUTER_DOMAIN:\s*\$\{PUTER_DOMAIN:-puter\.localhost\}/); + assert.match(composeContent, /PUTER_PROTOCOL:\s*\$\{PUTER_PROTOCOL:-http\}/); + }); + + it('does not exit early when bucket already exists', () => { + // Check that in the head-bucket branch, it does not exit 0 before put-bucket-cors + const headBucketMatch = composeContent.match(/if aws --endpoint-url "\$\$endpoint" s3api head-bucket[\s\S]*?fi/); + assert.ok(headBucketMatch, 'head-bucket check must be present'); + assert.ok(!headBucketMatch[0].includes('exit 0'), 'Existing bucket branch must NOT exit early'); + }); + + it('applies put-bucket-cors after bucket verification/creation', () => { + assert.match(composeContent, /aws --endpoint-url "\$\$endpoint" s3api put-bucket-cors/); + assert.match(composeContent, /--cors-configuration file:\/\/\/tmp\/cors\.json/); + }); + }); + + describe('6. Installer Configuration Consistency', () => { + it('install.sh writes PUTER_DOMAIN and PUTER_PROTOCOL to .env', () => { + const installSh = fs.readFileSync(INSTALL_SH_PATH, 'utf8'); + assert.match(installSh, /PUTER_DOMAIN=\$PUTER_DOMAIN/); + assert.match(installSh, /PUTER_PROTOCOL=\$PUTER_PROTOCOL/); + }); + + it('install.ps1 writes PUTER_DOMAIN and PUTER_PROTOCOL to .env', () => { + const installPs1 = fs.readFileSync(INSTALL_PS1_PATH, 'utf8'); + assert.match(installPs1, /PUTER_DOMAIN=\$PuterDomain/); + assert.match(installPs1, /PUTER_PROTOCOL=\$PuterProtocol/); + assert.match(installPs1, /protocol\s*=\s*\$PuterProtocol/); + assert.match(installPs1, /publicEndpoint\s*=\s*"\$\{PuterProtocol\}:\/\/s3\.\$PuterDomain"/); + }); + + it('.env.example documents PUTER_DOMAIN and PUTER_PROTOCOL', () => { + const envExample = fs.readFileSync(ENV_EXAMPLE_PATH, 'utf8'); + assert.match(envExample, /PUTER_DOMAIN=puter\.localhost/); + assert.match(envExample, /PUTER_PROTOCOL=http/); + }); + + it('doc/self-hosting.md explains PUTER_DOMAIN, PUTER_PROTOCOL, and S3 CORS', () => { + const doc = fs.readFileSync(DOC_PATH, 'utf8'); + assert.match(doc, /PUTER_DOMAIN=puter\.localhost/); + assert.match(doc, /PUTER_PROTOCOL=http/); + assert.match(doc, /PUTER_DOMAIN=example\.com/); + assert.match(doc, /PUTER_PROTOCOL=https/); + assert.match(doc, /s3-init/); + assert.match(doc, /CORS/); + }); + }); +});