Skip to content
Merged
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
4 changes: 4 additions & 0 deletions docs/admin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,10 @@ cd tools/playwright-verify && node verify-admin.mjs
```

- `http://localhost:8602/admin/?devlogin=<DEV_BYPASS_TOKEN>` で stg ログインをバイパス(バイパス identity `dev-admin@example.com` の allowlist 登録が前提)
- **ポートは 8601〜8610 のどれでもよい**(`PORT=8603 npm start` など)。非 prod の API は
この範囲の `http://localhost:<port>` を CORS で許可する(#1160)。devpod で worktree を
並行起動すると 8601 から順に別ポートが割り当たるため、範囲で許可している。
**prod はこの範囲を含まない**(含む設定で deploy しようとすると stack が throw する)
- ユニットテスト: `cd packages/admin && npm test`(eslint + jest)/ `cd infra/smalruby-admin && npm test`

## スクリーンショット
Expand Down
95 changes: 95 additions & 0 deletions infra/smalruby-admin/lambda/tests/cors-origins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* CORS 許可オリジンの決定ロジック (#1160)。
*
* ローカルの dev server は 8601 から順に使うため非 prod では範囲で許可するが、
* その緩和が prod に漏れないことが本質。ここが崩れると本番 API が localhost から
* 叩けてしまうので、範囲そのものより「prod に入らない」ことを厚めに固定する。
*/
import {
LOCAL_DEV_PORT_FIRST,
LOCAL_DEV_PORT_LAST,
isLocalOrigin,
localDevOrigins,
resolveCorsOrigins,
} from '../../lib/cors-origins';

const PROD_ORIGINS = ['https://smalruby.app', 'https://smalruby.jp'];

describe('localDevOrigins', () => {
test('8601 から 8610 までを両端込みで並べる', () => {
const origins = localDevOrigins();

expect(origins).toHaveLength(LOCAL_DEV_PORT_LAST - LOCAL_DEV_PORT_FIRST + 1);
expect(origins[0]).toBe('http://localhost:8601');
expect(origins[origins.length - 1]).toBe('http://localhost:8610');
});

test('範囲外のポートは含まない', () => {
const origins = localDevOrigins();

expect(origins).not.toContain('http://localhost:8600');
expect(origins).not.toContain('http://localhost:8611');
});
});

describe('isLocalOrigin', () => {
test.each([
'http://localhost:8601',
'http://localhost',
'https://localhost:8602',
'http://127.0.0.1:8601',
'http://[::1]:8601',
])('%s はローカル扱い', (origin) => {
expect(isLocalOrigin(origin)).toBe(true);
});

test.each([
'https://smalruby.app',
'https://smalruby.jp',
// ホスト名の一部に localhost を含むだけの別ドメインを取り違えない。
'https://localhost.example.com',
'https://notlocalhost',
])('%s は本番オリジン扱い', (origin) => {
expect(isLocalOrigin(origin)).toBe(false);
});
});

describe('resolveCorsOrigins', () => {
test('非 prod は本番オリジン + ローカル範囲', () => {
const origins = resolveCorsOrigins('stg', PROD_ORIGINS);

expect(origins).toEqual([...PROD_ORIGINS, ...localDevOrigins()]);
});

test('prod はローカルを一切含まない', () => {
const origins = resolveCorsOrigins('prod', PROD_ORIGINS);

expect(origins).toEqual(PROD_ORIGINS);
expect(origins.some(isLocalOrigin)).toBe(false);
});

test('env の指定が既定より優先される', () => {
const origins = resolveCorsOrigins('stg', PROD_ORIGINS, 'https://example.com, http://localhost:9999');

expect(origins).toEqual(['https://example.com', 'http://localhost:9999']);
});

test('prod に localhost を渡した env は deploy させない', () => {
expect(() =>
resolveCorsOrigins('prod', PROD_ORIGINS, 'https://smalruby.app,http://localhost:8601'),
).toThrow(/must not contain local origins in prod/);
});

test('prod でも本番オリジンだけの env は通る', () => {
expect(resolveCorsOrigins('prod', PROD_ORIGINS, 'https://smalruby.app')).toEqual([
'https://smalruby.app',
]);
});

test('env の空要素・余分な空白を落とす', () => {
expect(resolveCorsOrigins('stg', PROD_ORIGINS, ' https://a.example , , https://b.example ')).toEqual([
'https://a.example',
'https://b.example',
]);
});
});
13 changes: 8 additions & 5 deletions infra/smalruby-admin/lib/admin-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as path from 'path';
import { Construct } from 'constructs';

import { resolveCorsOrigins } from './cors-origins';

/**
* Smalruby Admin service (EPIC #1073): the operator-only management API.
*
Expand All @@ -34,11 +36,12 @@ export class SmalrubyAdminStack extends cdk.Stack {
const stage = this.node.tryGetContext('stage') || process.env.STAGE || 'stg';
const stageSuffix = stage === 'prod' ? '' : `-${stage}`;

const corsOriginsEnv = process.env.CORS_ALLOWED_ORIGINS ||
(stage === 'prod'
? 'https://smalruby.app'
: 'https://smalruby.app,http://localhost:8602');
const corsAllowOrigins = corsOriginsEnv.split(',').map(o => o.trim());
const corsAllowOrigins = resolveCorsOrigins(
stage,
['https://smalruby.app'],
process.env.CORS_ALLOWED_ORIGINS,
);
const corsOriginsEnv = corsAllowOrigins.join(',');

// Admin-dedicated Google OAuth client (decision B). An empty client ID
// would disable the audience check, so prod refuses to deploy without it
Expand Down
59 changes: 59 additions & 0 deletions infra/smalruby-admin/lib/cors-origins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* CORS 許可オリジンの組み立て (#1160)。
*
* ローカルの dev server は 8601 から順に使う(scratch-gui = 8601 / Admin SPA = 8602)。
* devpod で worktree を並行起動すると 8603 以降も割り当てられるため、非 prod では
* 範囲でまとめて許可する。HTTP API v2 の `allowOrigins` は**完全一致**で、ポートに
* ワイルドカードは使えないので列挙する。
*
* 各 infra プロジェクトは独立した npm パッケージで共有モジュールを持てないため、
* 同じ内容のファイルが他プロジェクトにもある。変更するときは揃えること。
*/

/** ローカル dev server に割り当てるポートの範囲(両端を含む)。 */
export const LOCAL_DEV_PORT_FIRST = 8601;
export const LOCAL_DEV_PORT_LAST = 8610;

/** ローカル開発で許可するオリジン(非 prod のみで使う)。 */
export const localDevOrigins = (): string[] => {
const origins: string[] = [];
for (let port = LOCAL_DEV_PORT_FIRST; port <= LOCAL_DEV_PORT_LAST; port += 1) {
origins.push(`http://localhost:${port}`);
}
return origins;
};

/** そのオリジンがローカル開発用(本番に出してはいけないもの)か。 */
export const isLocalOrigin = (origin: string): boolean =>
/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(origin.trim());

/**
* ステージに応じた許可オリジンを決める。
*
* env `CORS_ALLOWED_ORIGINS` が最優先。prod にローカルオリジンが混ざっていたら
* **deploy させない**(`DEV_BYPASS_TOKEN` の prod ガードと同じ考え方。CORS は
* 「開発中は緩めて戻し忘れる」が起きやすいので、戻し忘れを検知する側に倒す)。
* @param stage - デプロイ先ステージ
* @param productionOrigins - どのステージでも許可する本番オリジン
* @param envValue - `CORS_ALLOWED_ORIGINS` の値(未設定なら undefined)
* @returns 許可オリジンの配列
*/
export const resolveCorsOrigins = (
stage: string,
productionOrigins: string[],
envValue?: string,
): string[] => {
const origins = envValue
? envValue.split(',').map(o => o.trim()).filter(Boolean)
: [...productionOrigins, ...(stage === 'prod' ? [] : localDevOrigins())];

if (stage === 'prod') {
const local = origins.filter(isLocalOrigin);
if (local.length > 0) {
throw new Error(
`CORS_ALLOWED_ORIGINS must not contain local origins in prod: ${local.join(', ')}`,
);
}
}
return origins;
};
95 changes: 95 additions & 0 deletions infra/smalruby-bug-report/lambda/tests/cors-origins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* CORS 許可オリジンの決定ロジック (#1160)。
*
* ローカルの dev server は 8601 から順に使うため非 prod では範囲で許可するが、
* その緩和が prod に漏れないことが本質。ここが崩れると本番 API が localhost から
* 叩けてしまうので、範囲そのものより「prod に入らない」ことを厚めに固定する。
*/
import {
LOCAL_DEV_PORT_FIRST,
LOCAL_DEV_PORT_LAST,
isLocalOrigin,
localDevOrigins,
resolveCorsOrigins,
} from '../../lib/cors-origins';

const PROD_ORIGINS = ['https://smalruby.app', 'https://smalruby.jp'];

describe('localDevOrigins', () => {
test('8601 から 8610 までを両端込みで並べる', () => {
const origins = localDevOrigins();

expect(origins).toHaveLength(LOCAL_DEV_PORT_LAST - LOCAL_DEV_PORT_FIRST + 1);
expect(origins[0]).toBe('http://localhost:8601');
expect(origins[origins.length - 1]).toBe('http://localhost:8610');
});

test('範囲外のポートは含まない', () => {
const origins = localDevOrigins();

expect(origins).not.toContain('http://localhost:8600');
expect(origins).not.toContain('http://localhost:8611');
});
});

describe('isLocalOrigin', () => {
test.each([
'http://localhost:8601',
'http://localhost',
'https://localhost:8602',
'http://127.0.0.1:8601',
'http://[::1]:8601',
])('%s はローカル扱い', (origin) => {
expect(isLocalOrigin(origin)).toBe(true);
});

test.each([
'https://smalruby.app',
'https://smalruby.jp',
// ホスト名の一部に localhost を含むだけの別ドメインを取り違えない。
'https://localhost.example.com',
'https://notlocalhost',
])('%s は本番オリジン扱い', (origin) => {
expect(isLocalOrigin(origin)).toBe(false);
});
});

describe('resolveCorsOrigins', () => {
test('非 prod は本番オリジン + ローカル範囲', () => {
const origins = resolveCorsOrigins('stg', PROD_ORIGINS);

expect(origins).toEqual([...PROD_ORIGINS, ...localDevOrigins()]);
});

test('prod はローカルを一切含まない', () => {
const origins = resolveCorsOrigins('prod', PROD_ORIGINS);

expect(origins).toEqual(PROD_ORIGINS);
expect(origins.some(isLocalOrigin)).toBe(false);
});

test('env の指定が既定より優先される', () => {
const origins = resolveCorsOrigins('stg', PROD_ORIGINS, 'https://example.com, http://localhost:9999');

expect(origins).toEqual(['https://example.com', 'http://localhost:9999']);
});

test('prod に localhost を渡した env は deploy させない', () => {
expect(() =>
resolveCorsOrigins('prod', PROD_ORIGINS, 'https://smalruby.app,http://localhost:8601'),
).toThrow(/must not contain local origins in prod/);
});

test('prod でも本番オリジンだけの env は通る', () => {
expect(resolveCorsOrigins('prod', PROD_ORIGINS, 'https://smalruby.app')).toEqual([
'https://smalruby.app',
]);
});

test('env の空要素・余分な空白を落とす', () => {
expect(resolveCorsOrigins('stg', PROD_ORIGINS, ' https://a.example , , https://b.example ')).toEqual([
'https://a.example',
'https://b.example',
]);
});
});
59 changes: 59 additions & 0 deletions infra/smalruby-bug-report/lib/cors-origins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* CORS 許可オリジンの組み立て (#1160)。
*
* ローカルの dev server は 8601 から順に使う(scratch-gui = 8601 / Admin SPA = 8602)。
* devpod で worktree を並行起動すると 8603 以降も割り当てられるため、非 prod では
* 範囲でまとめて許可する。HTTP API v2 の `allowOrigins` は**完全一致**で、ポートに
* ワイルドカードは使えないので列挙する。
*
* 各 infra プロジェクトは独立した npm パッケージで共有モジュールを持てないため、
* 同じ内容のファイルが他プロジェクトにもある。変更するときは揃えること。
*/

/** ローカル dev server に割り当てるポートの範囲(両端を含む)。 */
export const LOCAL_DEV_PORT_FIRST = 8601;
export const LOCAL_DEV_PORT_LAST = 8610;

/** ローカル開発で許可するオリジン(非 prod のみで使う)。 */
export const localDevOrigins = (): string[] => {
const origins: string[] = [];
for (let port = LOCAL_DEV_PORT_FIRST; port <= LOCAL_DEV_PORT_LAST; port += 1) {
origins.push(`http://localhost:${port}`);
}
return origins;
};

/** そのオリジンがローカル開発用(本番に出してはいけないもの)か。 */
export const isLocalOrigin = (origin: string): boolean =>
/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(origin.trim());

/**
* ステージに応じた許可オリジンを決める。
*
* env `CORS_ALLOWED_ORIGINS` が最優先。prod にローカルオリジンが混ざっていたら
* **deploy させない**(`DEV_BYPASS_TOKEN` の prod ガードと同じ考え方。CORS は
* 「開発中は緩めて戻し忘れる」が起きやすいので、戻し忘れを検知する側に倒す)。
* @param stage - デプロイ先ステージ
* @param productionOrigins - どのステージでも許可する本番オリジン
* @param envValue - `CORS_ALLOWED_ORIGINS` の値(未設定なら undefined)
* @returns 許可オリジンの配列
*/
export const resolveCorsOrigins = (
stage: string,
productionOrigins: string[],
envValue?: string,
): string[] => {
const origins = envValue
? envValue.split(',').map(o => o.trim()).filter(Boolean)
: [...productionOrigins, ...(stage === 'prod' ? [] : localDevOrigins())];

if (stage === 'prod') {
const local = origins.filter(isLocalOrigin);
if (local.length > 0) {
throw new Error(
`CORS_ALLOWED_ORIGINS must not contain local origins in prod: ${local.join(', ')}`,
);
}
}
return origins;
};
12 changes: 9 additions & 3 deletions infra/smalruby-bug-report/lib/smalruby-bug-report-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as path from 'path';
import { Construct } from 'constructs';

import { resolveCorsOrigins } from './cors-origins';

export class SmalrubyBugReportStack extends cdk.Stack {
public readonly reportsTable: dynamodb.Table;
public readonly adminsTable: dynamodb.Table;
Expand All @@ -24,9 +26,13 @@ export class SmalrubyBugReportStack extends cdk.Stack {
const stage = this.node.tryGetContext('stage') || process.env.STAGE || 'stg';
const stageSuffix = stage === 'prod' ? '' : `-${stage}`;

const corsOriginsEnv = process.env.CORS_ALLOWED_ORIGINS ||
'https://smalruby.app,https://smalruby.jp,http://localhost:8601';
const corsAllowOrigins = corsOriginsEnv.split(',').map(o => o.trim());
// CORS設定。非 prod ではローカル dev server の範囲(8601-8610)も許可する。
const corsAllowOrigins = resolveCorsOrigins(
stage,
['https://smalruby.app', 'https://smalruby.jp'],
process.env.CORS_ALLOWED_ORIGINS,
);
const corsOriginsEnv = corsAllowOrigins.join(',');

const googleClientId = process.env.GOOGLE_CLIENT_ID || '';
const microsoftClientId = process.env.MICROSOFT_CLIENT_ID || '';
Expand Down
Loading
Loading